diff --git a/cmd/harnesscli/tui/components/slashcomplete/model.go b/cmd/harnesscli/tui/components/slashcomplete/model.go index 25032a1a0..5f3271434 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/model.go +++ b/cmd/harnesscli/tui/components/slashcomplete/model.go @@ -17,6 +17,9 @@ type Model struct { scrollOffset int // index of the first visible item in the scroll window active bool maxVisible int // max rows to show (default 8) + // navigated is set when the user moves the highlight with Up/Down and + // cleared whenever the query changes (see HasUserChoice). + navigated bool } // New creates a new Model seeded with the given suggestions. @@ -97,6 +100,7 @@ func (m Model) SetQuery(query string) Model { } m.selected = 0 m.scrollOffset = 0 + m.navigated = false return m } @@ -106,6 +110,7 @@ func (m Model) Down() Model { return m } m.selected = (m.selected + 1) % len(m.filtered) + m.navigated = true return m.clampScrollWindow() } @@ -115,6 +120,7 @@ func (m Model) Up() Model { return m } m.selected = (m.selected - 1 + len(m.filtered)) % len(m.filtered) + m.navigated = true return m.clampScrollWindow() } @@ -140,8 +146,21 @@ func (m Model) Filtered() []Suggestion { func (m Model) Accept() (Model, string) { s, ok := m.Selected() m.active = false + m.navigated = false if !ok { return m, "" } return m, "/" + s.Name + " " } + +// Query returns the current filter text (without the leading "/"). +func (m Model) Query() string { + return m.query +} + +// HasUserChoice reports whether the user has expressed a choice: typed a +// query or moved the highlight with Up/Down. A bare "/" with the default +// highlight is not a choice, so Enter must not run the first item (#1401). +func (m Model) HasUserChoice() bool { + return m.query != "" || m.navigated +} diff --git a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-120x40.txt b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-120x40.txt index e43080763..91ecc79d5 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-120x40.txt +++ b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-120x40.txt @@ -1,5 +1,6 @@ -▶ /clear Clear conversation history +▶ /clear Clear conversation history /context Show context usage grid /help Show help dialog /quit Quit the TUI /stats Show usage statistics + ↑↓ choose · Enter run · Tab complete · Esc close \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-200x50.txt b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-200x50.txt index e43080763..e198deaf7 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-200x50.txt +++ b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-200x50.txt @@ -1,5 +1,6 @@ -▶ /clear Clear conversation history +▶ /clear Clear conversation history /context Show context usage grid /help Show help dialog /quit Quit the TUI /stats Show usage statistics + ↑↓ choose · Enter run · Tab complete · Esc close \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-80x24.txt b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-80x24.txt index e43080763..f7ca742b9 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-80x24.txt +++ b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-042-autocomplete-80x24.txt @@ -1,5 +1,6 @@ -▶ /clear Clear conversation history +▶ /clear Clear conversation history /context Show context usage grid /help Show help dialog /quit Quit the TUI /stats Show usage statistics + ↑↓ choose · Enter run · Tab complete · Esc close \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-120x40.txt b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-120x40.txt index 1b0094784..ae97ee8f1 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-120x40.txt +++ b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-120x40.txt @@ -1,2 +1,3 @@ -▶ /clear Clear conversation history +▶ /clear Clear conversation history /context Show context usage grid + ↑↓ choose · Enter run · Tab complete · Esc close \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-200x50.txt b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-200x50.txt index 1b0094784..ee6691a67 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-200x50.txt +++ b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-200x50.txt @@ -1,2 +1,3 @@ -▶ /clear Clear conversation history +▶ /clear Clear conversation history /context Show context usage grid + ↑↓ choose · Enter run · Tab complete · Esc close \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-80x24.txt b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-80x24.txt index 1b0094784..224f05d35 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-80x24.txt +++ b/cmd/harnesscli/tui/components/slashcomplete/testdata/snapshots/TUI-047-fuzzy-80x24.txt @@ -1,2 +1,3 @@ -▶ /clear Clear conversation history +▶ /clear Clear conversation history /context Show context usage grid + ↑↓ choose · Enter run · Tab complete · Esc close \ No newline at end of file diff --git a/cmd/harnesscli/tui/components/slashcomplete/view.go b/cmd/harnesscli/tui/components/slashcomplete/view.go index 6b879db1a..b7ca41db2 100644 --- a/cmd/harnesscli/tui/components/slashcomplete/view.go +++ b/cmd/harnesscli/tui/components/slashcomplete/view.go @@ -8,14 +8,20 @@ import ( ) const ( - // selectedPrefix is prepended to the currently highlighted row. + // selectedPrefix marks the highlighted row; normalPrefix keeps the + // other rows aligned with it. Both are two columns wide. selectedPrefix = "▶ " - // normalPrefix is prepended to non-selected rows. - normalPrefix = " " + normalPrefix = " " + // footerHint tells a first-time user how to drive the menu (#1401). + footerHint = "↑↓ choose · Enter run · Tab complete · Esc close" + // noMatchHint replaces the list when the query matches nothing, so the + // menu never silently vanishes while the user is still typing (#1401). + noMatchHint = "No matching commands" + ellipsis = "…" ) -// View renders the dropdown overlay. -// Returns "" when the model is not active. +// View renders the dropdown overlay as a block of rows without a trailing +// newline. Returns "" when the model is not active. // width=0 defaults to 80. func (m Model) View(width int) string { if !m.active { @@ -30,44 +36,50 @@ func (m Model) View(width int) string { maxVis = 8 } + // Styles — built inline so view.go has no external theme dependency. + selectedStyle := lipgloss.NewStyle().Reverse(true) + dimStyle := lipgloss.NewStyle().Faint(true) + + // Columns available to a row after the two-column prefix. + available := width - lipgloss.Width(selectedPrefix) + if available < 1 { + available = 1 + } + fit := func(s string) string { return truncateWithEllipsis(s, available) } + filtered := m.filtered total := len(filtered) if total == 0 { - return "" + if m.query == "" { + return "" + } + return strings.Join([]string{ + normalPrefix + dimStyle.Render(fit(noMatchHint+" for \"/"+m.query+"\"")), + normalPrefix + dimStyle.Render(fit("Enter shows the unknown-command hint · Esc close")), + }, "\n") } - // Styles — built inline so view.go has no external theme dependency. - selectedStyle := lipgloss.NewStyle().Reverse(true) - dimStyle := lipgloss.NewStyle().Faint(true) - - // Determine the longest name for alignment (across the full list for stable columns). + // Name column width across the full filtered list for stable alignment. maxName := 0 for _, s := range filtered { - if len(s.Name) > maxName { - maxName = len(s.Name) + if w := lipgloss.Width(s.Name); w > maxName { + maxName = w } } - // Name column: "/" + name padded to maxName+1 - nameColWidth := maxName + 1 // +1 for leading "/" + nameColWidth := maxName + 1 // leading "/" - // Compute the scroll window: [windowStart, windowEnd). - // We need to reserve rows for indicators when items exist outside the window. - // Strategy: start with a maxVis window, then shrink for any needed indicator rows - // while keeping m.selected within the rendered range. + // Compute the scroll window: [windowStart, windowEnd), reserving rows + // for the "more above/below" indicators while keeping m.selected visible. windowStart := m.scrollOffset if windowStart < 0 { windowStart = 0 } - - // Determine which indicators are needed (based on raw window before shrinking). rawEnd := windowStart + maxVis if rawEnd > total { rawEnd = total } showAbove := windowStart > 0 showBelow := rawEnd < total - - // Compute effective content capacity after reserving indicator rows. contentCap := maxVis if showAbove { contentCap-- @@ -78,78 +90,63 @@ func (m Model) View(width int) string { if contentCap < 1 { contentCap = 1 } - - // Place the content window so that m.selected is always visible. - // Window: [windowStart, windowStart+contentCap). - // If selected is beyond the end, shift windowStart forward. if m.selected >= windowStart+contentCap { windowStart = m.selected - contentCap + 1 } - // If selected is before windowStart, bring windowStart back. if m.selected < windowStart { windowStart = m.selected } - // Clamp windowStart. if windowStart < 0 { windowStart = 0 } if windowStart >= total { windowStart = total - 1 } - windowEnd := windowStart + contentCap if windowEnd > total { windowEnd = total } - - // Recompute indicators based on final window position. showAbove = windowStart > 0 showBelow = windowEnd < total - var sb strings.Builder - + lines := make([]string, 0, maxVis+3) if showAbove { - indicator := fmt.Sprintf(" ▲ %d more above", windowStart) - sb.WriteString(dimStyle.Render(indicator) + "\n") + lines = append(lines, normalPrefix+dimStyle.Render(fit(fmt.Sprintf("▲ %d more above", windowStart)))) } - for i := windowStart; i < windowEnd; i++ { s := filtered[i] - isSelected := i == m.selected - - // Build the name portion: "/name " padded namePart := "/" + s.Name - padding := strings.Repeat(" ", nameColWidth-len(namePart)+2) - - // Build the full row content (without prefix) - rowContent := namePart + padding + s.Description - - // Trim to fit within width (prefix takes 2 chars) - available := width - len(selectedPrefix) - if available < 0 { - available = 0 - } - // Use rune-aware truncation - runes := []rune(rowContent) - if len(runes) > available { - runes = runes[:available] - rowContent = string(runes) - } - - var line string - if isSelected { - line = selectedPrefix + selectedStyle.Render(rowContent) + padding := strings.Repeat(" ", nameColWidth-lipgloss.Width(namePart)+2) + row := fit(namePart + padding + s.Description) + if i == m.selected { + // Pad so the highlight reads as a full-width bar, not a ragged + // strip that ends where the description happens to end. + row += strings.Repeat(" ", available-lipgloss.Width(row)) + lines = append(lines, selectedPrefix+selectedStyle.Render(row)) } else { - line = normalPrefix + rowContent + lines = append(lines, normalPrefix+row) } - sb.WriteString(line + "\n") } - if showBelow { - below := total - windowEnd - indicator := fmt.Sprintf(" ▼ %d more below", below) - sb.WriteString(dimStyle.Render(indicator) + "\n") + lines = append(lines, normalPrefix+dimStyle.Render(fit(fmt.Sprintf("▼ %d more below", total-windowEnd)))) } + lines = append(lines, normalPrefix+dimStyle.Render(fit(footerHint))) + return strings.Join(lines, "\n") +} - return sb.String() +// truncateWithEllipsis shortens s to at most width terminal columns, +// replacing the cut with "…" so the reader can tell text was dropped. +func truncateWithEllipsis(s string, width int) string { + if lipgloss.Width(s) <= width { + return s + } + if width <= 1 { + return ellipsis + } + runes := []rune(s) + // Trim runes until the text plus the ellipsis fits. + for len(runes) > 0 && lipgloss.Width(string(runes))+1 > width { + runes = runes[:len(runes)-1] + } + return strings.TrimRight(string(runes), " ") + ellipsis } diff --git a/cmd/harnesscli/tui/components/slashcomplete/view_polish_test.go b/cmd/harnesscli/tui/components/slashcomplete/view_polish_test.go new file mode 100644 index 000000000..5118c32d4 --- /dev/null +++ b/cmd/harnesscli/tui/components/slashcomplete/view_polish_test.go @@ -0,0 +1,76 @@ +package slashcomplete_test + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "go-agent-harness/cmd/harnesscli/tui/components/slashcomplete" +) + +// Issue #1401: the dropdown must read correctly to a first-time user. + +func polishSuggestions() []slashcomplete.Suggestion { + return []slashcomplete.Suggestion{ + {Name: "add-dir", Description: "Attach an extra directory to the session (/add-dir [remove] )"}, + {Name: "clear", Description: "Clear conversation history"}, + {Name: "help", Description: "Show help dialog"}, + } +} + +func TestView_NoMatchRow(t *testing.T) { + m := slashcomplete.New(polishSuggestions()).Open().SetQuery("zzz") + out := m.View(80) + if !strings.Contains(out, "No matching commands") { + t.Fatalf("no-match query must render a hint row, got %q", out) + } +} + +func TestView_EllipsisTruncation(t *testing.T) { + m := slashcomplete.New(polishSuggestions()).Open() + out := m.View(40) + for _, line := range strings.Split(out, "\n") { + if w := lipgloss.Width(line); w > 40 { + t.Errorf("line wider than terminal (%d > 40): %q", w, line) + } + } + if !strings.Contains(out, "…") { + t.Errorf("long description must be truncated with an ellipsis at width 40, got:\n%s", out) + } + if !strings.Contains(out, "/add-dir") { + t.Errorf("the command name must never be cut, got:\n%s", out) + } +} + +func TestView_NoTrailingNewline(t *testing.T) { + out := slashcomplete.New(polishSuggestions()).Open().View(80) + if strings.HasSuffix(out, "\n") { + t.Fatalf("View must not end with a newline (it produces a blank row in the screen stack)") + } +} + +func TestView_FooterHint(t *testing.T) { + out := slashcomplete.New(polishSuggestions()).Open().View(80) + for _, want := range []string{"↑↓", "Enter", "Tab", "Esc"} { + if !strings.Contains(out, want) { + t.Errorf("footer hint must mention %q, got:\n%s", want, out) + } + } +} + +func TestHasUserChoice(t *testing.T) { + m := slashcomplete.New(polishSuggestions()).Open().SetQuery("") + if m.HasUserChoice() { + t.Fatal("bare '/' with no navigation is not a choice") + } + if !m.Down().HasUserChoice() { + t.Fatal("navigating with Down is a choice") + } + if !m.SetQuery("he").HasUserChoice() { + t.Fatal("typing a query is a choice") + } + if m.Down().SetQuery("").HasUserChoice() { + t.Fatal("clearing the query resets the choice") + } +} diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index abfae2833..d9a715023 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -3308,6 +3308,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // When the dropdown is active, Enter accepts the selected suggestion // instead of submitting the input as a message. if m.slashComplete.IsActive() { + // A bare "/" with the default highlight is not a choice: running + // whatever happens to sort first would surprise a first-time + // user (#1401). Keep the menu open and say how to choose. + if !m.slashComplete.HasUserChoice() { + cmds = append(cmds, m.setStatusMsg("Type a command name or use ↑↓ to choose one, then press Enter")) + return m, tea.Batch(cmds...) + } newModel, accepted := m.slashComplete.Accept() m.slashComplete = newModel if accepted != "" { @@ -3740,6 +3747,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // The apikeys and model-config overlay arms above already matched earlier // in their own overlay-specific case branches, so this arm only fires // when no overlay is open. + // Tab with the slash menu open completes the highlighted command into + // the input without running it (#1401). Without this arm the key falls + // through to the input box's own prefix completer, which ignores the + // highlight the user just moved with the arrow keys. + case msg.Type == tea.KeyTab && m.slashComplete.IsActive() && !m.overlayActive: + newModel, accepted := m.slashComplete.Accept() + m.slashComplete = newModel + if accepted != "" { + m.input = m.input.SetValue(accepted) + } + return m, tea.Batch(cmds...) + case msg.Type == tea.KeyCtrlU && !m.overlayActive: m.input = m.input.Clear() cmds = append(cmds, m.setStatusMsg("Input cleared")) @@ -5601,6 +5620,15 @@ func (m Model) View() string { sections = append(sections, bannerView) } if dropdownView != "" { + // The menu borrows its rows from the top of the main content so the + // screen keeps a constant height and the input/status bar stay put + // (#1401). The bottom of the transcript is the part worth keeping. + mainLines := strings.Split(sections[0], "\n") + if drop := lipgloss.Height(dropdownView); drop < len(mainLines) { + sections[0] = strings.Join(mainLines[drop:], "\n") + } else { + sections[0] = "" + } sections = append(sections, dropdownView) } sections = append(sections, inputView, sep, statusBarView) diff --git a/cmd/harnesscli/tui/slash_menu_1401_test.go b/cmd/harnesscli/tui/slash_menu_1401_test.go new file mode 100644 index 000000000..8ceece780 --- /dev/null +++ b/cmd/harnesscli/tui/slash_menu_1401_test.go @@ -0,0 +1,72 @@ +package tui_test + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// Issue #1401: slash-command menu behaviour a first-time user expects. + +// Tab completes the item the user highlighted with the arrow keys, not the +// input box's own prefix guess. +func TestSlashMenu_TabCompletesHighlighted(t *testing.T) { + m := initModel(t, 120, 40) + m = typeIntoModel(m, "/co") + m = sendKey(m, tea.KeyDown) // move off the top match + m = typeTab(m) + got := m.Input() + if got == "/co" || !strings.HasPrefix(got, "/co") || !strings.HasSuffix(got, " ") { + t.Fatalf("Tab must complete the highlighted command into the input, got %q", got) + } + // Tab must fill the input, not execute the command. + if m.OverlayActive() { + t.Fatalf("Tab must not run the command (an overlay opened)") + } +} + +// Enter on a bare "/" with nothing typed and no navigation must not run the +// first command in the list. +func TestSlashMenu_BareSlashEnterDoesNotRun(t *testing.T) { + m := initModel(t, 120, 40) + m = typeIntoModel(m, "/") + m = sendKey(m, tea.KeyEnter) + if m.OverlayActive() { + t.Fatalf("Enter on bare '/' must not open the first command's overlay") + } + if m.Input() != "/" { + t.Fatalf("input must be preserved, got %q", m.Input()) + } + if !strings.Contains(m.StatusMsg(), "↑↓") { + t.Fatalf("status must explain how to choose a command, got %q", m.StatusMsg()) + } +} + +// Enter after choosing with the arrow keys still runs the chosen command. +func TestSlashMenu_EnterAfterDownRuns(t *testing.T) { + m := initModel(t, 120, 40) + m = typeIntoModel(m, "/") + for i := 0; i < 3; i++ { // add-dir, attach, cancel, clear + m = sendKey(m, tea.KeyDown) + } + m = sendKey(m, tea.KeyEnter) + if m.Input() != "" { + t.Fatalf("Enter on a navigated item must run it and clear the input, got %q", m.Input()) + } +} + +// Opening the menu must not change the total screen height: the transcript +// viewport gives up the rows the menu needs. +func TestSlashMenu_ScreenHeightStableWhenOpen(t *testing.T) { + m := initModel(t, 120, 40) + before := strings.Count(m.View(), "\n") + m = typeIntoModel(m, "/") + after := strings.Count(m.View(), "\n") + if after != before { + t.Fatalf("screen height changed when the menu opened: %d -> %d rows", before+1, after+1) + } + if !strings.Contains(m.View(), "/add-dir") { + t.Fatalf("menu must be visible") + } +} diff --git a/cmd/harnesscli/tui/slashcomplete_dropdown_test.go b/cmd/harnesscli/tui/slashcomplete_dropdown_test.go index aac9a8c6d..8f6864ff7 100644 --- a/cmd/harnesscli/tui/slashcomplete_dropdown_test.go +++ b/cmd/harnesscli/tui/slashcomplete_dropdown_test.go @@ -203,9 +203,13 @@ func TestDropdown_UpWrapsToBottom(t *testing.T) { func TestDropdown_EnterAcceptsSelection(t *testing.T) { m := initModel(t, 120, 40) m = typeIntoModel(m, "/") + // #1401: a bare "/" is not a choice; move the highlight first so Enter + // accepts a command the user actually picked. + m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = m2.(tui.Model) // Press Enter to accept the currently highlighted suggestion. - m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m2, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = m2.(tui.Model) view := m.View() diff --git a/cmd/harnesscli/tui/tabcomplete_test.go b/cmd/harnesscli/tui/tabcomplete_test.go index 04b9adb23..c0b2ec2e3 100644 --- a/cmd/harnesscli/tui/tabcomplete_test.go +++ b/cmd/harnesscli/tui/tabcomplete_test.go @@ -32,35 +32,23 @@ func TestTabCompletion_SlashCommand(t *testing.T) { } } -// TestTabCompletion_MultiMatch verifies that typing "/" + Tab leaves the input -// unchanged (or completes to a common prefix if one exists) since all commands -// share the "/" prefix already. +// TestTabCompletion_MultiMatch verifies that with the slash menu open, Tab +// completes the highlighted item (the top match for a bare "/") into the +// input without running it (#1401). The input box's common-prefix completer +// only applies when the menu is closed. func TestTabCompletion_MultiMatch(t *testing.T) { m := initModel(t, 80, 24) m = typeIntoModel(m, "/") - before := m.Input() m = typeTab(m) after := m.Input() - - // All commands start with "/" so the common prefix is "/" itself — the input - // must not get longer or shorter after Tab when already at the common prefix. - if after != before { - // Accept either no change or a valid common-prefix extension. - // The only valid extension would be a string that is a prefix of every - // registered command. Validate that the result is a prefix of at least - // one known command. - knownCmds := []string{"/clear", "/help", "/context", "/stats", "/quit", "/export", "/subagents", "/model", "/history"} - for _, cmd := range knownCmds { - if strings.HasPrefix(cmd, after) { - // A valid partial completion — acceptable. - return - } - } - t.Errorf("Tab on /: unexpected result %q (was %q)", after, before) + if !strings.HasPrefix(after, "/") || !strings.HasSuffix(after, " ") || len(after) < 3 { + t.Errorf("Tab on / must complete the highlighted command, got %q", after) + } + if m.OverlayActive() { + t.Errorf("Tab must not run the completed command") } } -// TestTabCompletion_NoMatchIsNoop verifies that Tab on a non-matching prefix is a no-op. func TestTabCompletion_NoMatchIsNoop(t *testing.T) { m := initModel(t, 80, 24) m = typeIntoModel(m, "/zzz") diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 4c0ce3a01..216863917 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -1,5 +1,11 @@ # Engineering Log +## 2026-09-06 — Slash-command menu polish (#1401) + +- Symptom: driving `harnesscli --tui` in tmux, Tab ignored the item highlighted with ↑/↓ (the input box's prefix completer handled the key), Enter on a bare `/` ran `/add-dir`, a query with no matches made the menu vanish, descriptions were chopped mid-word at 40-60 columns, a blank row appeared under the menu, no key hint was shown, and the menu added rows to the screen (38 → 47 at 120x40) instead of borrowing them from the transcript. +- Cause: `components/slashcomplete/view.go` returned "" for empty results and a trailing newline otherwise, truncated by rune count without an ellipsis, and the model only wired Enter to the dropdown; the screen stack appended the dropdown without shrinking the viewport. +- Fix: `Model.HasUserChoice` (query typed or ↑/↓ used) gates Enter; a Tab arm in `model.go` accepts the highlighted item into the input; `View` renders a dim no-match row, `…` truncation, a full-width highlight bar, a footer hint, and no trailing newline; `View()` drops the top rows of the main content by the menu height. Snapshot goldens regenerated. Live tmux captures at 120x40, 60x20 and 40x15 attached to the PR. + ## 2026-09-05 — Issue #1372 workspace_path silently ignored - Cause: harnesscli (`resolveWorkspacePath`, defaulting to the CLI's cwd) and diff --git a/website/docs/cli/tui.md b/website/docs/cli/tui.md index d93e92e99..67a4c12f7 100644 --- a/website/docs/cli/tui.md +++ b/website/docs/cli/tui.md @@ -98,7 +98,7 @@ Press `?` or `Ctrl+H` at any time to open the built-in help dialog with the full | `PgDn` | Scroll viewport down half a screen | | `/` | Open slash-command autocomplete dropdown | | `@` | Insert `@` to begin a file-path attachment | -| `Tab` | Complete a slash command or file path | +| `Tab` | With the slash menu open: complete the highlighted command into the input (does not run it). Otherwise: complete a file path after `@` | | `Ctrl+O` | Expand/collapse active tool card, or toggle plan mode when idle | | `Ctrl+E` | Open `$EDITOR` for multi-line prompt editing | | `Ctrl+S` | Copy last assistant response to clipboard | @@ -143,7 +143,7 @@ While a run is in flight, type corrective input and press `Ctrl+G` to inject it ## Slash commands -Type `/` to open the autocomplete dropdown. `Tab` completes to the common prefix; `Enter` selects a command. Commands are case-insensitive. +Type `/` to open the command menu. `↑`/`↓` move the highlight, `Enter` runs the highlighted command, `Tab` completes it into the input without running it, and `Esc` closes the menu (a second `Esc` clears the input). A bare `/` plus `Enter` does not run anything; type part of a name or move the highlight first. When nothing matches, the menu says so instead of disappearing; `Enter` then shows the unknown-command hint. Descriptions are shortened with `…` on narrow terminals, and the menu takes its rows from the top of the transcript so the input and status bar never move. Commands are case-insensitive. | Command | Description | |---|---|