diff --git a/internal/tui/input.go b/internal/tui/input.go index 57a8f78..07bd72f 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -63,6 +63,8 @@ var slashMenuItems = []SlashMenuItem{ {"/reconnect", "Restore active MCP transport"}, {"/status", "Auth + session info"}, {"/cost", "Token usage + cost"}, + {"/context", "Show session context info"}, + {"/plan", "Show coding-plan usage window"}, {"/config", "Show config"}, {"/skills", "List qmax QA skills + install status"}, {"/sessions", "List saved sessions"}, @@ -75,8 +77,10 @@ var slashMenuItems = []SlashMenuItem{ {"/paste", "Paste from clipboard (image or text)"}, {"/queue", "Show or add to prompt queue"}, {"/set", "Update config"}, + {"/gemma", "Gemma 4 31B on Cerebras (none|low|medium|high, off)"}, {"/ollama", "Toggle Ollama on/off"}, {"/clear", "Clear history"}, + {"/update", "Self-update qmax-code to the latest release"}, {"/quit", "Exit"}, } @@ -356,6 +360,20 @@ func (m inputModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEnter: + // A fully-typed command always wins: if the filter exactly names a + // command, submit that command verbatim no matter which menu row the + // selection sits on. Typing /update used to submit /set — /update was + // missing from the menu and /set's description ("Update config") + // description-matched the filter, so Enter picked the only visible row. + if exact := "/" + strings.ToLower(strings.TrimSpace(m.filter)); exact != "/" { + for _, item := range slashMenuItems { + if strings.ToLower(item.Cmd) == exact { + m.result = item.Cmd + m.done = true + return m, tea.Quit + } + } + } if len(filtered) > 0 && m.menu < len(filtered) { m.result = filtered[m.menu].Cmd m.done = true @@ -398,15 +416,22 @@ func (m inputModel) filteredMenuItems() []SlashMenuItem { if m.filter == "" { return slashMenuItems } - var filtered []SlashMenuItem + // Command-name matches rank ahead of description matches: when the user + // has typed something that names a command (e.g. "update" → /update), a + // description coincidence on another entry (e.g. /set's "Update config") + // must not outrank it. lower := strings.ToLower(m.filter) + var cmdMatches, descMatches []SlashMenuItem for _, item := range slashMenuItems { - if strings.Contains(strings.ToLower(item.Cmd), lower) || - strings.Contains(strings.ToLower(item.Desc), lower) { - filtered = append(filtered, item) + if strings.Contains(strings.ToLower(item.Cmd), lower) { + cmdMatches = append(cmdMatches, item) + continue + } + if strings.Contains(strings.ToLower(item.Desc), lower) { + descMatches = append(descMatches, item) } } - return filtered + return append(cmdMatches, descMatches...) } var ( diff --git a/internal/tui/input_test.go b/internal/tui/input_test.go index 02b4fe9..4fb3e43 100644 --- a/internal/tui/input_test.go +++ b/internal/tui/input_test.go @@ -124,6 +124,83 @@ func TestInputCtrlOTogglesFromMenuMode(t *testing.T) { } } +// TestSlashMenuFilterRanksCmdMatchAheadOfDescMatch pins the /update → /set bug: +// /set's description ("Update config") description-matches the filter "update", +// so when /update is also in the table the command-name match must rank first. +func TestSlashMenuFilterRanksCmdMatchAheadOfDescMatch(t *testing.T) { + m := newInputModel("qmax > ", nil) + m.mode = modeMenu + m.filter = "update" + + filtered := m.filteredMenuItems() + if len(filtered) < 2 { + t.Fatalf("filter 'update' should match /update by Cmd and /set by Desc, got %d items", len(filtered)) + } + if filtered[0].Cmd != "/update" { + t.Fatalf("first filtered item = %q, want /update (Cmd match outranks Desc match)", filtered[0].Cmd) + } + foundSet := false + for _, item := range filtered[1:] { + if item.Cmd == "/set" { + foundSet = true + } + } + if !foundSet { + t.Errorf("/set (Desc 'Update config') should still be reachable, ranked after Cmd matches") + } +} + +// TestSlashMenuEnterSubmitsExactTypedCommand pins the hijack itself: with the +// filter exactly naming a command, Enter must submit that command verbatim — +// not whichever description-matched row the selection happens to sit on. +func TestSlashMenuEnterSubmitsExactTypedCommand(t *testing.T) { + m := newInputModel("qmax > ", nil) + m.mode = modeMenu + m.filter = "update" + + filtered := m.filteredMenuItems() + setIdx := -1 + for i, item := range filtered { + if item.Cmd == "/set" { + setIdx = i + } + } + if setIdx < 0 { + t.Fatal("precondition: /set should be in the filtered list via its description") + } + // Point the selection straight at the /set row — the exact-match rule must + // still submit /update. + m.menu = setIdx + + updated, _ := m.updateMenu(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(inputModel) + if !ok { + t.Fatalf("updateMenu returned %T, want inputModel", updated) + } + if !next.done || next.result != "/update" { + t.Fatalf("Enter with filter 'update' selected /set: result = %q done=%v, want /update", next.result, next.done) + } +} + +// TestSlashMenuCoversCriticalCommands guards the other half of the bug: a +// command handled by the REPL but missing from the menu can never be typed +// exactly — the menu intercepts "/" and Enter submits a filtered row instead. +func TestSlashMenuCoversCriticalCommands(t *testing.T) { + have := map[string]bool{} + for _, item := range slashMenuItems { + have[item.Cmd] = true + } + critical := []string{ + "/update", "/context", "/gemma", "/plan", // were missing + "/orch", "/help", "/set", "/clear", "/quit", "/gate", + } + for _, cmd := range critical { + if !have[cmd] { + t.Errorf("%s handled by the REPL but missing from the slash menu", cmd) + } + } +} + func TestInputCtrlXClearStreakResets(t *testing.T) { m := newInputModel("qmax > ", nil) m.text = "keep text"