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
19 changes: 19 additions & 0 deletions cmd/harnesscli/tui/components/slashcomplete/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -97,6 +100,7 @@ func (m Model) SetQuery(query string) Model {
}
m.selected = 0
m.scrollOffset = 0
m.navigated = false
return m
}

Expand All @@ -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()
}

Expand All @@ -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()
}

Expand All @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
▶ /clear Clear conversation history
▶ /clear Clear conversation history
/context Show context usage grid
↑↓ choose · Enter run · Tab complete · Esc close
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
▶ /clear Clear conversation history
▶ /clear Clear conversation history
/context Show context usage grid
↑↓ choose · Enter run · Tab complete · Esc close
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
▶ /clear Clear conversation history
▶ /clear Clear conversation history
/context Show context usage grid
↑↓ choose · Enter run · Tab complete · Esc close
131 changes: 64 additions & 67 deletions cmd/harnesscli/tui/components/slashcomplete/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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--
Expand All @@ -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
}
76 changes: 76 additions & 0 deletions cmd/harnesscli/tui/components/slashcomplete/view_polish_test.go
Original file line number Diff line number Diff line change
@@ -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] <path>)"},
{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")
}
}
Loading
Loading