package tui import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" "codey/internal/skills" ) // The menu belongs to a slash command being typed, and to nothing else: a // message that merely mentions one must not open it. func TestMenuQuery(t *testing.T) { cases := []struct { in string want string wantOpen bool }{ {"/", "", true}, {"/ef", "ef", true}, {"/EF", "ef", true}, {"", "", false}, {"hello", "", false}, {"see /help for that", "", false}, {"/effort high", "", false}, // the name is settled; the menu is done {"/help\nmore", "", false}, // a second line means a message, not a command {" /help", "", false}, } for _, tc := range cases { got, open := menuQuery(tc.in) if open != tc.wantOpen || got != tc.want { t.Errorf("menuQuery(%q) = %q, %v; want %q, %v", tc.in, got, open, tc.want, tc.wantOpen) } } } func names(cmds []command) []string { var out []string for _, c := range cmds { out = append(out, c.name) } return out } // Typing narrows the list, and a name that starts with what was typed beats // one that merely contains it. func TestMatchCommandsRanksPrefixesFirst(t *testing.T) { all := []command{ {name: "allowed"}, {name: "clear"}, {name: "effort"}, {name: "reformat-everything"}, } cases := map[string][]string{ "": {"allowed", "clear", "effort", "reformat-everything"}, "e": {"effort", "allowed", "clear", "reformat-everything"}, "ef": {"effort", "reformat-everything"}, "format": {"reformat-everything"}, "zz": nil, } for typed, want := range cases { got := names(matchCommands(all, typed)) if strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("matchCommands(%q) = %v, want %v", typed, got, want) } } } // The catalogue is the union of what the TUI answers itself and what the // session discovered, so a skill is reachable from the menu by name. func TestCommandsListBuiltinsAndSkills(t *testing.T) { m := newTestModel(t) m.opts.Skills = []skills.Skill{ {Name: "brainstorming", Description: "explore an idea", Source: "personal"}, } got := names(m.commands()) for _, want := range []string{"help", "effort", "skills", "exit", "brainstorming"} { if !strings.Contains(strings.Join(got, ","), want) { t.Errorf("command %q missing from the catalogue %v", want, got) } } for _, c := range m.commands() { if c.desc == "" { t.Errorf("command /%s has no description to show in the menu", c.name) } } } // Every name the menu offers must be one submit actually answers; otherwise // the menu is advertising commands that report themselves as unknown. func TestEveryOfferedCommandIsHandled(t *testing.T) { catalogue := newTestModel(t).commands() for _, c := range catalogue { m := newTestModel(t) m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}} m.submit("/" + c.name) if out := m.tr.render(); strings.Contains(out, "unknown command") { t.Errorf("/%s is offered by the menu but not handled: %q", c.name, out) } } } // Moving and accepting: the highlighted name lands in the prompt box, and // accepting is not sending — the turn starts on the next enter. func TestMenuCompletesWithoutSending(t *testing.T) { m := newTestModel(t) typeIn(m, "/") if !m.menuOpen() { t.Fatal("typing a slash did not open the menu") } first := m.menu.items[0].name m.Update(key("down")) second := m.menu.items[m.menu.idx].name if second == first { t.Fatal("down did not move the highlight") } m.Update(key("enter")) if m.menuOpen() { t.Error("the menu stayed open after accepting") } if got := m.input.Value(); !strings.HasPrefix(got, "/"+second) { t.Errorf("input = %q, want it completed to /%s", got, second) } if m.phase != phaseIdle { t.Error("accepting a completion started a turn; it should only fill the box") } } // A command that takes arguments is completed with the space already typed; // one that does not is completed bare and the menu does not re-open on it. func TestCompletionTrailingSpaceFollowsArguments(t *testing.T) { m := newTestModel(t) m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}} typeIn(m, "/brainstorm") m.Update(key("tab")) if got := m.input.Value(); got != "/brainstorming " { t.Errorf("input = %q, want a trailing space for a command that takes arguments", got) } m2 := newTestModel(t) typeIn(m2, "/clear") m2.Update(key("tab")) if got := m2.input.Value(); got != "/clear" { t.Errorf("input = %q, want no trailing space on an argument-free command", got) } if m2.menuOpen() { t.Error("the menu re-opened on the text it had just completed") } } // Escape is for dismissing the menu, and only that: the text survives. func TestEscapeClosesMenuKeepingText(t *testing.T) { m := newTestModel(t) typeIn(m, "/help") if !m.menuOpen() { t.Fatal("menu not open") } m.Update(key("esc")) if m.menuOpen() { t.Error("escape did not close the menu") } if got := m.input.Value(); got != "/help" { t.Errorf("input = %q, want the typed text left alone", got) } // With the menu gone, enter sends what was typed. m.Update(key("enter")) if !strings.Contains(m.tr.render(), "commands:") { t.Error("enter after dismissing the menu did not run the command") } } // Typing past the last match closes the menu rather than showing an empty box. func TestMenuClosesWhenNothingMatches(t *testing.T) { m := newTestModel(t) typeIn(m, "/zzzq") if m.menuOpen() { t.Errorf("menu open with no matches: %v", names(m.menu.items)) } } // The menu sits in the bottom chrome, which must still leave the transcript // somewhere to live on a short terminal. func TestMenuFitsTheTerminal(t *testing.T) { for _, h := range []int{10, 24, 60} { m := newTestModel(t) m.Update(tea.WindowSizeMsg{Width: 80, Height: h}) typeIn(m, "/") if !m.menuOpen() { t.Fatalf("height %d: menu did not open", h) } view := m.View() if got := strings.Count(view, "\n") + 1; got > h { t.Errorf("height %d: view is %d lines, one row of the menu too many", h, got) } if !strings.Contains(view, "/help") { t.Errorf("height %d: the menu rendered nothing", h) } } } // While the agent has the turn, enter is refused and esc means interrupt, so // a menu opened under it would offer keys that do not do what it says. func TestMenuStaysShutWhileTheAgentWorks(t *testing.T) { m := newTestModel(t) m.phase = phaseRunning typeIn(m, "/he") if m.menuOpen() { t.Error("the menu opened during a turn") } m.Update(key("esc")) if !m.interrupting { t.Error("esc was eaten by the menu instead of interrupting the turn") } }