package tui import ( "strings" "testing" tea "charm.land/bubbletea/v2" "github.com/smallnest/pigo/internal/runtime" ) // typeInto feeds each rune of s to the model as a key press, returning the // evolved model. It mirrors how a terminal delivers typed characters (Code + // Text), including the leading "/" of a slash-command. func typeInto(t *testing.T, m tea.Model, s string) tea.Model { t.Helper() for _, r := range s { m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) } return m } // menuNames returns the "/name" of every candidate currently in the popup. func menuNames(m Model) []string { out := make([]string, len(m.menu.filtered)) for i, c := range m.menu.filtered { out[i] = "/" + c.Name } return out } func containsAll(hay []string, needles ...string) bool { set := make(map[string]bool, len(hay)) for _, h := range hay { set[h] = true } for _, n := range needles { if !set[n] { return false } } return true } // TestSlashMenuOpensOnSlash verifies that typing a bare "/" opens the popup with // the built-in commands present (/model, /help among them). func TestSlashMenuOpensOnSlash(t *testing.T) { m := typeInto(t, NewModel(Options{}), "/").(Model) if !m.menu.active { t.Fatalf("menu should be active after typing '/'") } names := menuNames(m) if !containsAll(names, "/model", "/help") { t.Errorf("candidate set %v missing /model or /help", names) } } // TestSlashMenuFiltersByPrefix verifies the popup narrows to the typed prefix: // "/mo" keeps /model and /models but drops /help. func TestSlashMenuFiltersByPrefix(t *testing.T) { m := typeInto(t, NewModel(Options{}), "/mo").(Model) if !m.menu.active { t.Fatalf("menu should be active for '/mo'") } names := menuNames(m) if !containsAll(names, "/model", "/models") { t.Errorf("candidate set %v missing /model or /models", names) } for _, n := range names { if !strings.HasPrefix(n, "/mo") { t.Errorf("candidate %q does not match prefix /mo (set %v)", n, names) } } } // TestSlashMenuClosesOnSpace verifies name-completion stops once the buffer moves // on to arguments (a space after the command name closes the popup). func TestSlashMenuClosesOnSpace(t *testing.T) { m := typeInto(t, NewModel(Options{}), "/model ").(Model) if m.menu.active { t.Errorf("menu should close once the command name is complete (buffer %q)", m.input.Value()) } } // TestSlashMenuNavigation verifies arrow keys move the highlighted candidate and // wrap at the ends. func TestSlashMenuNavigation(t *testing.T) { m := typeInto(t, NewModel(Options{}), "/").(Model) n := len(m.menu.filtered) if n < 2 { t.Fatalf("need at least two candidates to test navigation, got %d", n) } next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if got := next.(Model).menu.selected; got != 1 { t.Errorf("after Down, selected = %d, want 1", got) } // Up from index 0 wraps to the last candidate. back, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) if got := back.(Model).menu.selected; got != n-1 { t.Errorf("after Up from 0, selected = %d, want %d (wrap)", got, n-1) } } // TestSlashTabCompletes verifies Tab fills the buffer with the highlighted // command and closes the popup (ready for arguments). func TestSlashTabCompletes(t *testing.T) { m := typeInto(t, NewModel(Options{}), "/hel").(Model) got, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) gm := got.(Model) if gm.input.Value() != "/help " { t.Errorf("after Tab, buffer = %q, want %q", gm.input.Value(), "/help ") } if gm.menu.active { t.Errorf("menu should close after Tab completion") } } // TestSlashHelpExecutesIntoTranscript verifies executing a built-in action // command (/help) renders its output into the transcript as a system block — // listing the available commands — without requiring a live provider. func TestSlashHelpExecutesIntoTranscript(t *testing.T) { m := typeInto(t, NewModel(Options{}), "/help").(Model) got, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) gm := got.(Model) // No run is started for an action command. if cmd != nil { if msg := cmd(); msg != nil { if _, isQuit := msg.(tea.QuitMsg); isQuit { t.Fatalf("/help should not quit") } } } if gm.running { t.Errorf("/help is an action command; model should stay idle") } joined := strings.Join(blockTexts(gm.transcript), "\n") if !strings.Contains(joined, "/help") || !strings.Contains(joined, "/model") { t.Errorf("/help output should list commands (/help, /model); transcript:\n%s", joined) } if gm.input.Value() != "" { t.Errorf("after executing /help, input = %q, want cleared", gm.input.Value()) } } // TestSlashUnknownCommandReported verifies an unknown "/name" surfaces the // resolver error into the transcript rather than being sent to the agent. func TestSlashUnknownCommandReported(t *testing.T) { m := typeInto(t, NewModel(Options{}), "/definitelynotacommand").(Model) // The popup filters to nothing, so it is inactive; Enter routes through submit. got, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) gm := got.(Model) if gm.running { t.Errorf("an unknown command must not start a run") } joined := strings.Join(blockTexts(gm.transcript), "\n") if !strings.Contains(joined, "unknown command") { t.Errorf("expected an unknown-command notice in transcript, got:\n%s", joined) } } // TestSlashMenuRendersAboveInput verifies the popup appears in the View while // active, so the candidate list is visible above the input line. func TestSlashMenuRendersAboveInput(t *testing.T) { m := NewModel(Options{}) next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = typeInto(t, next, "/mo").(Model) view := m.View() if !strings.Contains(view.Content, "/model") { t.Errorf("active popup should render /model in the view content") } } // TestSlashPromptCommandStartsRun verifies a prompt (Expand) command's expanded // text is fed to the run seam, not shown as a bare status. A stub startRunFn // stands in for the live provider. func TestSlashPromptCommandStartsRun(t *testing.T) { m := NewModel(Options{}) // Inject a user prompt command directly into the registry. m.slash.AddUser(runtime.SlashCommand{ Name: "greet", Expand: func(args string) string { return "hello " + args }, }) var ran string m.startRunFn = func(prompt string) (chan tea.Msg, tea.Cmd) { ran = prompt ch := make(chan tea.Msg, 1) return ch, func() tea.Msg { return nil } } got, _ := m.runSlash("/greet world") gm := got.(Model) if ran != "hello world" { t.Errorf("prompt command should start a run with expanded text; got %q", ran) } if !gm.running { t.Errorf("model should be running after a prompt command") } } // TestSlashExitQuits verifies that /exit and /quit typed in the TUI input box // terminate the program (tea.Quit + quitting flag), mirroring the REPL loop // which intercepts them before slash resolution. func TestSlashExitQuits(t *testing.T) { for _, cmd := range []string{"/exit", "/quit"} { got, teaCmd := NewModel(Options{}).runSlash(cmd) gm := got.(Model) if !gm.quitting { t.Errorf("%s: model should be marked quitting", cmd) } if teaCmd == nil { t.Fatalf("%s: expected a tea.Quit command, got nil", cmd) } if _, isQuit := teaCmd().(tea.QuitMsg); !isQuit { t.Errorf("%s: cmd should be tea.Quit", cmd) } } }