From 96ada0267778424d3ae6a47f8678ea01b9e79f1b Mon Sep 17 00:00:00 2001 From: Yu Yi Date: Wed, 10 Jun 2026 10:54:17 -0400 Subject: [PATCH] tui: show all slash commands in / picker; add scroll indicators to @ picker (closes #356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash-command popup reused the @-file picker's 8-row scroll window with no indicator, so a bare `/` showed clear…model and silently hid resume, session, tools, tree, and usage — it looked like the autocomplete was missing commands. - slash picker: no scroll window — the command set is small and bounded, so every match is always visible. - @-file picker: keeps its 8-row window (workspaces are unbounded) but now renders `↑ N more` / `↓ N more` lines when the window clips. - both pickers expose popupRows() and layout() uses it, so the popup height math can't drift from what render emits. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +++++ cmd/glue/tui/atpicker.go | 52 ++++++++++++++++++++++++----- cmd/glue/tui/atpicker_test.go | 57 ++++++++++++++++++++++++++++++++ cmd/glue/tui/slashpicker.go | 28 ++++++++++------ cmd/glue/tui/slashpicker_test.go | 20 +++++++++++ cmd/glue/tui/tui.go | 20 ++--------- 6 files changed, 149 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d394bd..e793c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **TUI: `/` picker shows every command; `@` picker gains scroll + indicators (`cmd/glue/tui`).** The slash-command popup previously + reused the file picker's 8-row scroll window with no indicator, so a + bare `/` silently hid 5 of the 13 commands. It now lists the whole + (bounded) command set, and the `@`-file picker — which keeps its + window because workspaces are unbounded — shows `↑/↓ N more` lines + when matches are clipped. (#356) + ## 1.13.0 — 2026-06-09 - **Per-model capability registry + tool-owned prompt assembly diff --git a/cmd/glue/tui/atpicker.go b/cmd/glue/tui/atpicker.go index 4ee3677..ce36359 100644 --- a/cmd/glue/tui/atpicker.go +++ b/cmd/glue/tui/atpicker.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "io/fs" "os" "path/filepath" @@ -141,6 +142,39 @@ func (p *atPicker) refilter(query string) { } } +// windowBounds returns the [start, end) slice of matches currently +// visible: at most atPickerVisibleRows rows, scrolled to keep the +// cursor in view. +func (p *atPicker) windowBounds() (start, end int) { + if p.cursor >= atPickerVisibleRows { + start = p.cursor - atPickerVisibleRows + 1 + } + end = start + atPickerVisibleRows + if end > len(p.matches) { + end = len(p.matches) + } + return start, end +} + +// popupRows is the number of body rows the rendered popup occupies +// between the title and the key hint — visible matches plus the +// "↑/↓ N more" indicator lines when the window clips. Layout height +// math in (*Model) must use this so the frame never truncates. +func (p *atPicker) popupRows() int { + if len(p.matches) == 0 { + return 1 // "(no matches)" row + } + start, end := p.windowBounds() + rows := end - start + if start > 0 { + rows++ + } + if end < len(p.matches) { + rows++ + } + return rows +} + func (p *atPicker) up() { if p == nil || len(p.matches) == 0 { return @@ -262,14 +296,12 @@ func renderAtPicker(p *atPicker, width int) string { b.WriteString(atRow.Render(" (no matches)")) } else { // Scroll window: keep the cursor visible. Show at most - // atPickerVisibleRows rows. - start := 0 - if p.cursor >= atPickerVisibleRows { - start = p.cursor - atPickerVisibleRows + 1 - } - end := start + atPickerVisibleRows - if end > len(p.matches) { - end = len(p.matches) + // atPickerVisibleRows rows, with "N more" indicators so + // off-window matches are discoverable. + start, end := p.windowBounds() + if start > 0 { + b.WriteString(keyHint.Render(fmt.Sprintf(" ↑ %d more", start))) + b.WriteByte('\n') } for i := start; i < end; i++ { row := p.files[p.matches[i]] @@ -283,6 +315,10 @@ func renderAtPicker(p *atPicker, width int) string { b.WriteByte('\n') } } + if rest := len(p.matches) - end; rest > 0 { + b.WriteByte('\n') + b.WriteString(keyHint.Render(fmt.Sprintf(" ↓ %d more", rest))) + } } b.WriteByte('\n') b.WriteString(keyHint.Render(" ↑/↓ navigate · Tab/Enter insert · Esc cancel")) diff --git a/cmd/glue/tui/atpicker_test.go b/cmd/glue/tui/atpicker_test.go index 22550a0..79350cd 100644 --- a/cmd/glue/tui/atpicker_test.go +++ b/cmd/glue/tui/atpicker_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -179,3 +180,59 @@ func TestAlwaysAllowPermission(t *testing.T) { t.Fatalf("RememberFor = %v, want RememberSession", got.RememberFor) } } + +// TestAtPickerWindowIndicators verifies the "↑/↓ N more" lines that make +// off-window matches discoverable, and that popupRows accounts for them +// so the layout height math never truncates the popup frame. +func TestAtPickerWindowIndicators(t *testing.T) { + t.Parallel() + files := make([]string, 12) + for i := range files { + files[i] = fmt.Sprintf("file%02d.go", i) + } + p := &atPicker{files: files} + p.refilter("") + if len(p.matches) != 12 { + t.Fatalf("matches = %d, want 12", len(p.matches)) + } + + // Cursor at top: window is [0, 8) → only a "↓ 4 more" tail. + out := renderAtPicker(p, 100) + if !strings.Contains(out, "↓ 4 more") || strings.Count(out, " more") != 1 { + t.Fatalf("top window indicators wrong:\n%s", out) + } + if got := p.popupRows(); got != atPickerVisibleRows+1 { + t.Fatalf("popupRows = %d, want %d (window + ↓ line)", got, atPickerVisibleRows+1) + } + + // Cursor mid-list: both indicators. + for i := 0; i < 8; i++ { + p.down() + } + // cursor = 8 → window [1, 9): 1 above, 3 below. + out = renderAtPicker(p, 100) + if !strings.Contains(out, "↑ 1 more") || !strings.Contains(out, "↓ 3 more") { + t.Fatalf("mid window indicators wrong:\n%s", out) + } + if got := p.popupRows(); got != atPickerVisibleRows+2 { + t.Fatalf("popupRows = %d, want %d (window + both lines)", got, atPickerVisibleRows+2) + } + + // Cursor at bottom: window is [4, 12) → only a "↑ 4 more" head. + for i := 0; i < 8; i++ { + p.down() + } + out = renderAtPicker(p, 100) + if !strings.Contains(out, "↑ 4 more") || strings.Count(out, " more") != 1 { + t.Fatalf("bottom window indicators wrong:\n%s", out) + } + + // Few matches: no indicators, popupRows = match count. + p.refilter("file00") + if got := p.popupRows(); got != 1 { + t.Fatalf("popupRows = %d, want 1", got) + } + if out = renderAtPicker(p, 100); strings.Contains(out, " more") { + t.Fatalf("unexpected indicator with a single match:\n%s", out) + } +} diff --git a/cmd/glue/tui/slashpicker.go b/cmd/glue/tui/slashpicker.go index e6d7864..1c13fb2 100644 --- a/cmd/glue/tui/slashpicker.go +++ b/cmd/glue/tui/slashpicker.go @@ -75,6 +75,17 @@ func specMatches(s slashSpec, q string) bool { return false } +// popupRows is the number of body rows the rendered popup occupies +// between the title and the key hint. Unlike the @-file picker there is +// no scroll window — the command set is small and bounded, and hiding +// entries made `/` look like it didn't list every command. +func (p *slashPicker) popupRows() int { + if len(p.matches) == 0 { + return 1 // "(no matching command)" row + } + return len(p.matches) +} + func (p *slashPicker) up() { if p == nil || len(p.matches) == 0 { return @@ -153,23 +164,18 @@ func renderSlashPicker(p *slashPicker, width int) string { if len(p.matches) == 0 { b.WriteString(atRow.Render(" (no matching command)")) } else { - start := 0 - if p.cursor >= atPickerVisibleRows { - start = p.cursor - atPickerVisibleRows + 1 - } - end := start + atPickerVisibleRows - if end > len(p.matches) { - end = len(p.matches) - } - for i := start; i < end; i++ { - s := p.specs[p.matches[i]] + // No scroll window: the command set is small and bounded, so a + // bare `/` always shows every command. (The @-file picker keeps + // its window because workspaces are unbounded.) + for i, idx := range p.matches { + s := p.specs[idx] row := truncate(s.display()+" — "+s.Desc, w-6) if i == p.cursor { b.WriteString(atSel.Render("› " + row)) } else { b.WriteString(atRow.Render(" " + row)) } - if i < end-1 { + if i < len(p.matches)-1 { b.WriteByte('\n') } } diff --git a/cmd/glue/tui/slashpicker_test.go b/cmd/glue/tui/slashpicker_test.go index 2a12d73..2dcdb3b 100644 --- a/cmd/glue/tui/slashpicker_test.go +++ b/cmd/glue/tui/slashpicker_test.go @@ -245,3 +245,23 @@ func TestDescribeCommandsAndPickerShareSpecs(t *testing.T) { } } } + +// TestSlashPickerShowsEveryCommandOnBareSlash pins the fix for #356: the +// popup must list the entire (small, bounded) command set with no scroll +// window — hiding entries made `/` look like incomplete autocomplete. +func TestSlashPickerShowsEveryCommandOnBareSlash(t *testing.T) { + t.Parallel() + p := newSlashPicker() + if got := p.popupRows(); got != len(slashSpecs()) { + t.Fatalf("popupRows = %d, want %d (every command visible)", got, len(slashSpecs())) + } + out := renderSlashPicker(p, 120) + for _, s := range slashSpecs() { + if !strings.Contains(out, "/"+s.Name) { + t.Errorf("bare / popup missing /%s:\n%s", s.Name, out) + } + } + if strings.Contains(out, "more") { + t.Fatalf("slash popup should never show a scroll indicator:\n%s", out) + } +} diff --git a/cmd/glue/tui/tui.go b/cmd/glue/tui/tui.go index 8ffe2bd..7672ea5 100644 --- a/cmd/glue/tui/tui.go +++ b/cmd/glue/tui/tui.go @@ -452,27 +452,13 @@ func (m *Model) layout() { // viewport's vertical space while it's open. Height: title(1) + // matches(<=visible) + hint(1) + borders(2). if m.atPicker != nil { - rows := len(m.atPicker.matches) - if rows > atPickerVisibleRows { - rows = atPickerVisibleRows - } - if rows == 0 { - rows = 1 // "(no matches)" row - } - bottomH += rows + 4 + bottomH += m.atPicker.popupRows() + 4 } // The /command popup occupies the same slot as the @-picker (they are - // never open together). Same height math: title(1) + matches + hint(1) + // never open together). Same height math: title(1) + body rows + hint(1) // + borders(2). if m.slashPicker != nil { - rows := len(m.slashPicker.matches) - if rows > atPickerVisibleRows { - rows = atPickerVisibleRows - } - if rows == 0 { - rows = 1 // "(no matching command)" row - } - bottomH += rows + 4 + bottomH += m.slashPicker.popupRows() + 4 } bodyH := m.height - headerH - statusH - bottomH if bodyH < 3 {