207 lines
6.7 KiB
Go
207 lines
6.7 KiB
Go
// This file implements slash-commands and their autocomplete popup for the
|
||
// full-screen TUI (US-008, FR-15). It is the TUI counterpart to the REPL's
|
||
// slash handling (internal/cli/repl/repl.go): both front-ends consult the SAME
|
||
// shared registry assembled by internal/cli/prompts.BuildSlashRegistry (#383),
|
||
// so /model, /help, user-declared templates (~/.pigo/{commands,prompts}),
|
||
// config/CLI prompt templates, plugin commands and ~/.agents/skills /skill-name
|
||
// commands are identical across the two surfaces.
|
||
//
|
||
// tui deliberately imports prompts/runtime/cli (the shared lower layers), never
|
||
// repl: prompts sits below both front-ends, so there is no import cycle.
|
||
//
|
||
// The autocomplete popup (slashMenu) activates while the input buffer is a
|
||
// "/name" being typed (a leading "/" with no whitespace yet). It filters the
|
||
// registry by the typed prefix, is navigated with the arrow keys, completed with
|
||
// Tab, and run with Enter — the model intercepts those keys before delegating to
|
||
// the textarea (see model.handleKey).
|
||
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/smallnest/pigo/internal/cli"
|
||
"github.com/smallnest/pigo/internal/cli/prompts"
|
||
"github.com/smallnest/pigo/internal/runtime"
|
||
)
|
||
|
||
// maxMenuRows caps how many candidate rows the popup shows at once; a longer
|
||
// filtered list scrolls a window around the selection so the overlay stays a few
|
||
// lines tall regardless of how many commands are registered.
|
||
const maxMenuRows = 8
|
||
|
||
// newSlashRegistry assembles the shared slash-command registry for the TUI the
|
||
// same way the REPL does: built-ins seeded from runtime, the live-state /model
|
||
// and /help commands bound to live (so a /model switch mutates the very config
|
||
// the run loop reads), user/plugin/skill/template commands from disk. A load
|
||
// error is non-fatal — BuildSlashRegistry still returns a registry with the
|
||
// built-ins, so the TUI stays usable and the failure is surfaced on stderr.
|
||
func newSlashRegistry(opts Options, live *cli.LiveConfig) *runtime.SlashRegistry {
|
||
reg, err := prompts.BuildSlashRegistry(live, opts.Skills, opts.Plugins, prompts.PromptTemplateSources{
|
||
Settings: opts.ConfigPrompts,
|
||
CLI: opts.CliPrompts,
|
||
Disable: opts.NoPromptTemplates,
|
||
})
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "pigo: slash-commands: %v\n", err)
|
||
}
|
||
return reg
|
||
}
|
||
|
||
// slashMenu is the autocomplete popup state. It holds the candidates matching
|
||
// the current "/prefix" and the highlighted row; it is inactive (rendered as
|
||
// nothing) whenever the buffer is not a slash-command being typed or no command
|
||
// matches the prefix.
|
||
type slashMenu struct {
|
||
theme Theme
|
||
active bool
|
||
filtered []runtime.SlashCommand
|
||
selected int
|
||
}
|
||
|
||
// newSlashMenu builds an inactive menu bound to the theme used for its rows.
|
||
func newSlashMenu(theme Theme) slashMenu { return slashMenu{theme: theme} }
|
||
|
||
// slashToken reports whether buffer is a slash-command name still being typed
|
||
// and returns the text after the leading "/". It is true only for a leading "/"
|
||
// with no whitespace yet: once the user types a space the name is complete and
|
||
// the buffer has moved on to arguments, so name-completion stops.
|
||
func slashToken(buffer string) (token string, ok bool) {
|
||
trimmed := strings.TrimLeft(buffer, " \t")
|
||
if !strings.HasPrefix(trimmed, "/") {
|
||
return "", false
|
||
}
|
||
rest := trimmed[1:]
|
||
if strings.ContainsAny(rest, " \t\n") {
|
||
return "", false
|
||
}
|
||
return rest, true
|
||
}
|
||
|
||
// refresh recomputes the menu from the current buffer and registry. It activates
|
||
// only when the buffer is a "/name" prefix that matches at least one command;
|
||
// otherwise it deactivates and clears its candidates. The selection is clamped
|
||
// so it stays in range as the filtered set shrinks.
|
||
func (mn *slashMenu) refresh(buffer string, reg *runtime.SlashRegistry) {
|
||
token, ok := slashToken(buffer)
|
||
if !ok || reg == nil {
|
||
mn.close()
|
||
return
|
||
}
|
||
var out []runtime.SlashCommand
|
||
for _, c := range reg.List() {
|
||
if strings.HasPrefix(c.Name, token) {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
mn.filtered = out
|
||
mn.active = len(out) > 0
|
||
if mn.selected >= len(out) || mn.selected < 0 {
|
||
mn.selected = 0
|
||
}
|
||
}
|
||
|
||
// rows reports how many terminal rows the popup occupies when rendered, so the
|
||
// model can reserve that space above the input line during relayout. It is zero
|
||
// while inactive and otherwise the visible window height (min of the candidate
|
||
// count and maxMenuRows).
|
||
func (mn slashMenu) rows() int {
|
||
if !mn.active || len(mn.filtered) == 0 {
|
||
return 0
|
||
}
|
||
if len(mn.filtered) > maxMenuRows {
|
||
return maxMenuRows
|
||
}
|
||
return len(mn.filtered)
|
||
}
|
||
|
||
// close deactivates the menu and drops its candidates.
|
||
func (mn *slashMenu) close() {
|
||
mn.active = false
|
||
mn.filtered = nil
|
||
mn.selected = 0
|
||
}
|
||
|
||
// moveUp / moveDown cycle the highlighted candidate, wrapping at the ends so
|
||
// arrow navigation is continuous.
|
||
func (mn *slashMenu) moveUp() {
|
||
if len(mn.filtered) == 0 {
|
||
return
|
||
}
|
||
mn.selected--
|
||
if mn.selected < 0 {
|
||
mn.selected = len(mn.filtered) - 1
|
||
}
|
||
}
|
||
|
||
func (mn *slashMenu) moveDown() {
|
||
if len(mn.filtered) == 0 {
|
||
return
|
||
}
|
||
mn.selected++
|
||
if mn.selected >= len(mn.filtered) {
|
||
mn.selected = 0
|
||
}
|
||
}
|
||
|
||
// current returns the highlighted candidate, or ok=false when the menu is
|
||
// inactive / empty.
|
||
func (mn slashMenu) current() (runtime.SlashCommand, bool) {
|
||
if !mn.active || mn.selected < 0 || mn.selected >= len(mn.filtered) {
|
||
return runtime.SlashCommand{}, false
|
||
}
|
||
return mn.filtered[mn.selected], true
|
||
}
|
||
|
||
// view renders the popup as a block of up to maxMenuRows lines, the highlighted
|
||
// row marked with a "›" caret and accented. Each row is "/name description",
|
||
// truncated to the width so it never wraps. Returns "" when inactive so the
|
||
// model omits the overlay entirely (and its row) while idle.
|
||
func (mn slashMenu) view(width int) string {
|
||
if !mn.active || len(mn.filtered) == 0 {
|
||
return ""
|
||
}
|
||
start, end := mn.window()
|
||
rowWidth := width - 2 // reserve the caret / indent column
|
||
if rowWidth < 1 {
|
||
rowWidth = width
|
||
}
|
||
var b strings.Builder
|
||
for i := start; i < end; i++ {
|
||
c := mn.filtered[i]
|
||
line := "/" + c.Name
|
||
if c.Description != "" {
|
||
line += " " + c.Description
|
||
}
|
||
line = TruncateToWidth(line, rowWidth)
|
||
if i == mn.selected {
|
||
b.WriteString(mn.theme.Accent.Render("› " + line))
|
||
} else {
|
||
b.WriteString(mn.theme.System.Render(" " + line))
|
||
}
|
||
if i < end-1 {
|
||
b.WriteByte('\n')
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// window returns the [start,end) slice of filtered candidates to display,
|
||
// scrolled to keep the selection visible when the list is taller than
|
||
// maxMenuRows.
|
||
func (mn slashMenu) window() (int, int) {
|
||
n := len(mn.filtered)
|
||
if n <= maxMenuRows {
|
||
return 0, n
|
||
}
|
||
start := mn.selected - maxMenuRows + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
if start > n-maxMenuRows {
|
||
start = n - maxMenuRows
|
||
}
|
||
return start, start + maxMenuRows
|
||
}
|