first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
// 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
}