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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 44 additions & 8 deletions cmd/glue/tui/atpicker.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"fmt"
"io/fs"
"os"
"path/filepath"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]]
Expand All @@ -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"))
Expand Down
57 changes: 57 additions & 0 deletions cmd/glue/tui/atpicker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
}
}
28 changes: 17 additions & 11 deletions cmd/glue/tui/slashpicker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
}
}
Expand Down
20 changes: 20 additions & 0 deletions cmd/glue/tui/slashpicker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
20 changes: 3 additions & 17 deletions cmd/glue/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading