Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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"},
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
77 changes: 77 additions & 0 deletions internal/tui/input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading