Files
2026-08-14 23:41:57 +08:00

207 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}