From 7b7ba092548d4ef107d4c3329dfe6c4dcf8adb59 Mon Sep 17 00:00:00 2001 From: Will Su Date: Thu, 15 Oct 2026 18:31:16 -0400 Subject: [PATCH 1/4] feat(tui): make an expanded accordion's whole region the click/hover target When a block (e.g. reasoning) is expanded, every disclosed row becomes part of the click target that collapses it, and hover highlights the whole region with two tones: a stronger fill on the header, a softer accordionDetailHover fill (45% accordionHover over the background) on the disclosed rows below it. A collapsed block keeps only its header row as the target. - chat.List: HeaderAt renamed to AccordionAt and extended via accordionAtRow to cover every row of an expanded item; hoverFillRows computes the (possibly header-scrolled-off) row span renderSurface paints. - styles: add Accordion.DetailHoverBackground, wired from a new accordionDetailHover palette token (dark #1C1E26, light #D7DCE5). - update.go/model.go: HeaderAt -> AccordionAt at the mouse-down site; the pendingAccordionToggle comment now reflects the larger target. - The hand pointer shape (reconcilePointerShape) already derives from List.Hovered, so it follows the whole expanded region automatically. - Tests: rewrote list_test.go's HeaderAt coverage into TestAccordionAtCoversTheExpandedBlockAndNothingElse plus a header-scrolled- off case, added TestHoverPaintsTheWholeExpandedBlock, and re-aimed TestMouseWheelScrollRefreshesHoverUnderTheStationaryPointer at the real intent (hover retargets on scroll, rather than always clearing). Added TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion covering the hand-pointer regression explicitly. Co-Authored-By: Claude Opus 5 --- internal/tui/accordion_input_test.go | 72 ++++++++++++++++++++++--- internal/tui/chat/list.go | 67 +++++++++++++++++++---- internal/tui/chat/list_test.go | 81 +++++++++++++++++++++++----- internal/tui/components/accordion.go | 5 +- internal/tui/model.go | 3 +- internal/tui/styles/builder.go | 3 +- internal/tui/styles/palette.go | 3 ++ internal/tui/styles/styles.go | 14 +++-- internal/tui/update.go | 2 +- 9 files changed, 210 insertions(+), 40 deletions(-) diff --git a/internal/tui/accordion_input_test.go b/internal/tui/accordion_input_test.go index 239bd604..af165c3f 100644 --- a/internal/tui/accordion_input_test.go +++ b/internal/tui/accordion_input_test.go @@ -186,9 +186,48 @@ func TestMouseMotionSetsPointerShapeOverAnAccordionRow(t *testing.T) { } } +// TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion: the hand +// pointer shape follows List.Hovered, so it covers an expanded block's whole +// disclosed region, not just its header. A collapsed block has no disclosed +// rows, so the row right after its header stays the default shape. +func TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion(t *testing.T) { + m := accordionTestModel(t) + row := accordionRow(t, m) + + // reconcilePointerShape only emits the OSC 22 sequence when the shape + // actually changes (see its doc comment), so establish a known "hand on" + // baseline on the header before checking a transition away from it. + if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row}); rawSequence(cmd) != ansi.SetPointerShape("pointer") { + t.Fatal("hovering the header did not set the hand pointer shape") + } + // Collapsed: nothing below the header row belongs to the block. + if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row + 1}); rawSequence(cmd) != ansi.SetPointerShape("default") { + t.Fatal("a collapsed block's body row set the hand pointer shape") + } + + m.list.ToggleAllDisclosure() + // Every disclosed row of accordionTestOutput (5 lines) is now part of the + // one click target, including the last one — and so gets the hand too. + for offset := 1; offset <= 5; offset++ { + m.Update(tea.MouseMotionMsg{X: 0, Y: row + offset}) + if !m.list.Hovered() { + t.Fatalf("disclosed row at offset %d did not report hover", offset) + } + } + + // One row past the expanded block's last disclosed row is outside it, so + // the hand must come back off. + if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row + 6}); rawSequence(cmd) != ansi.SetPointerShape("default") { + t.Fatal("the row after the expanded block kept the hand pointer shape") + } +} + // scrollableAccordionTestModel builds several identical tool blocks in a // viewport too short to show them all, so ScrollBy actually moves the -// offset instead of no-oping at AtBottom(). +// offset instead of no-oping at AtBottom(). Each block's output is a single +// line, so an expanded block occupies exactly two rows (header + one detail +// row) — small enough that a single wheel notch can cross a block boundary +// inside the 3-row viewport this fixture's window size produces. func scrollableAccordionTestModel(t *testing.T) *Model { t.Helper() m := newShell() @@ -198,7 +237,7 @@ func scrollableAccordionTestModel(t *testing.T) *Model { blocks = append(blocks, agent.Block{ ID: agent.BlockID{Scope: agent.ScopeTool, Key: fmt.Sprintf("call-%d", i)}, Kind: assistant.KindToolResult, - Tool: &agent.ToolBlock{Name: "search_logs", Status: agent.ToolSuccess, Output: accordionTestOutput}, + Tool: &agent.ToolBlock{Name: "search_logs", Status: agent.ToolSuccess, Output: "row 1"}, }) } m.transcript.Blocks = blocks @@ -223,22 +262,39 @@ func visibleAccordionRow(t *testing.T, m *Model) int { return -1 } +// TestMouseWheelScrollRefreshesHoverUnderTheStationaryPointer: every row of +// an expanded block is a hover target, so a wheel notch that slides a +// *different* block under a stationary pointer must retarget hover to that +// block rather than keep reporting the one that scrolled away. Hover stays +// set across the notch; it is the targeted block that changes. func TestMouseWheelScrollRefreshesHoverUnderTheStationaryPointer(t *testing.T) { m := scrollableAccordionTestModel(t) - // Expanded blocks put detail rows, not the next header, under the pointer - // once the wheel scrolls. m.list.ToggleAllDisclosure() m.list.ScrollToTop() - row := visibleAccordionRow(t, m) + // One row below the chevron is still inside the first block's expanded + // region (its lone detail row): a stable fixture to point at regardless of + // how the chevron itself happens to line up with the viewport. + row := visibleAccordionRow(t, m) + 1 + before, ok := m.list.AccordionAt(row) + if !ok { + t.Fatalf("row %d is not an accordion target before scrolling", row) + } m.Update(tea.MouseMotionMsg{X: 0, Y: row}) if !m.list.Hovered() { - t.Fatal("expected hovering the chevron to set hover") + t.Fatal("expected hovering a detail row to set hover") } m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 0, Y: row}) - if m.list.Hovered() { - t.Fatal("scrolling moved the accordion row out from under the pointer, but hover was not recomputed") + after, ok := m.list.AccordionAt(row) + if !ok { + t.Fatalf("row %d is not an accordion target after scrolling", row) + } + if !m.list.Hovered() { + t.Fatal("scrolling a new block under the stationary pointer did not hover it") + } + if after == before { + t.Fatalf("fixture did not advance to a different block after one wheel notch: still %v", before) } } diff --git a/internal/tui/chat/list.go b/internal/tui/chat/list.go index 62121072..8b0d6a26 100644 --- a/internal/tui/chat/list.go +++ b/internal/tui/chat/list.go @@ -371,23 +371,62 @@ func (l *List) SetPointerRow(y int) { l.pointerY = y } // SetPointer records the viewport cell under the mouse. func (l *List) SetPointer(x, y int) { l.pointerX, l.pointerY = x, y } -// Hovered reports whether the pointer sits over a clickable accordion row. +// Hovered reports whether the pointer sits over a clickable accordion zone. func (l *List) Hovered() bool { - _, ok := l.HeaderAt(l.pointerY) + _, ok := l.AccordionAt(l.pointerY) return ok } // HoveredLink returns the rendered link beneath the stationary pointer. func (l *List) HoveredLink() (LinkHit, bool) { return l.LinkAt(l.pointerX, l.pointerY) } -// HeaderAt returns the block whose clickable accordion header sits on viewport -// row y. The whole row is the target, so only y matters. -func (l *List) HeaderAt(y int) (agent.BlockID, bool) { - idx, line, ok := l.itemAtRow(y) - if ok && line == 0 && l.gutter(l.view[idx]) > 0 { - return l.view[idx].id, true +// AccordionAt returns the block whose clickable accordion zone sits on +// viewport row y. A collapsed block's only target is its header row; an +// expanded block's target is every row it renders, since the whole disclosed +// region collapses it back. +func (l *List) AccordionAt(y int) (agent.BlockID, bool) { + idx, _, ok := l.accordionAtRow(y) + if !ok { + return agent.BlockID{}, false + } + return l.view[idx].id, true +} + +// accordionAtRow maps a viewport row to the accordion item it targets, if +// any. Row 0 of a guttered item is always a target (the header); later rows +// are targets only once the item is expanded, since a collapsed item's +// compact body rows carry no disclosure semantics. +func (l *List) accordionAtRow(y int) (idx, line int, ok bool) { + idx, line, ok = l.itemAtRow(y) + if !ok || l.gutter(l.view[idx]) == 0 { + return 0, 0, false + } + if line > 0 && !l.expanded(l.view[idx].id) { + return 0, 0, false } - return agent.BlockID{}, false + return idx, line, true +} + +// hoverFill is the viewport row span an accordion hover paints, split into +// the stronger header tone and the softer detail tone that covers the rest +// of an expanded block. header may be negative when the block's header has +// scrolled above the viewport; start/end are already clamped to [0, height]. +type hoverFill struct { + header, start, end int +} + +// hoverFillRows returns the rows the pointer's accordion hover should paint. +// The header may have scrolled above row 0, and the item may run past the +// viewport bottom, so both ends are clamped independently of header, which +// stays unclamped to tell the paint loop whether row 0 is itself the header. +func (l *List) hoverFillRows() (hoverFill, bool) { + idx, line, ok := l.accordionAtRow(l.pointerY) + if !ok { + return hoverFill{}, false + } + header := l.pointerY - line + end := header + len(l.entry(idx).lines) + return hoverFill{header: header, start: max(header, 0), end: min(end, l.height)}, true } // itemAtRow maps a viewport row to a rendered presentation item. Gaps and @@ -728,8 +767,14 @@ func (l *List) renderSurface(withPosition bool) Surface { for len(lines) < budget { lines = append(lines, "") } - if l.Hovered() { - lines[l.pointerY] = components.PaintRowBackground(lines[l.pointerY], l.width, l.sty.Accordion.HoverBackground) + if fill, ok := l.hoverFillRows(); ok { + for row := fill.start; row < fill.end; row++ { + bg := l.sty.Accordion.DetailHoverBackground + if row == fill.header { + bg = l.sty.Accordion.HoverBackground + } + lines[row] = components.PaintRowBackground(lines[row], l.width, bg) + } } top := 0 if withPosition { diff --git a/internal/tui/chat/list_test.go b/internal/tui/chat/list_test.go index b8f14aaf..0cd1d507 100644 --- a/internal/tui/chat/list_test.go +++ b/internal/tui/chat/list_test.go @@ -345,7 +345,7 @@ func TestReasoningExpandsToItsThinkingText(t *testing.T) { if !strings.Contains(compact, "▶ reasoning") || strings.Contains(compact, "idea") { t.Fatalf("reasoning did not start collapsed on its header:\n%s", compact) } - if _, ok := list.HeaderAt(0); !ok { + if _, ok := list.AccordionAt(0); !ok { t.Fatal("reasoning header is not clickable") } @@ -763,7 +763,7 @@ func TestNothingToExpandDrawsNoGutter(t *testing.T) { list.SetHeight(8) list.SetItems([]agent.Block{tc.block}) - if _, ok := list.HeaderAt(0); ok { + if _, ok := list.AccordionAt(0); ok { t.Fatal("block with nothing to expand is clickable") } bare := list.renderPresentationItem(list.view[0], renderContext{width: list.width, sty: list.sty, frame: list.frame}) @@ -815,25 +815,44 @@ func TestDocumentAgreesWithVisibleSurfaceAcrossCollapseAndScroll(t *testing.T) { } } -func TestHeaderAtOnlyMatchesAVisibleHeaderRow(t *testing.T) { +// TestAccordionAtCoversTheExpandedBlockAndNothingElse: an expanded block's +// disclosed rows are part of its click target, a collapsed block's compact +// body rows are not, and neither extends past the viewport. +func TestAccordionAtCoversTheExpandedBlockAndNothingElse(t *testing.T) { list := listWithTool(agent.ToolSuccess) list.ToggleDisclosure(list.view[0].id) // give the header detail rows list.ScrollToTop() // drop follow so the header starts visible list.SetHeight(2) - if id, ok := list.HeaderAt(0); !ok || id != list.view[0].id { - t.Fatalf("HeaderAt(0) = %v, %t, want the tool's header", id, ok) + if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { + t.Fatalf("AccordionAt(0) = %v, %t, want the tool's header", id, ok) } - if _, ok := list.HeaderAt(1); ok { - t.Fatal("HeaderAt matched a detail row") + if id, ok := list.AccordionAt(1); !ok || id != list.view[0].id { + t.Fatalf("AccordionAt(1) = %v, %t, want the tool's disclosed detail row", id, ok) } - if _, ok := list.HeaderAt(2); ok { - t.Fatal("HeaderAt matched a row outside the viewport") + if _, ok := list.AccordionAt(2); ok { + t.Fatal("AccordionAt matched a row outside the viewport") } + list.ToggleDisclosure(list.view[0].id) // collapse again + if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { + t.Fatalf("AccordionAt(0) = %v, %t, want the collapsed block's header", id, ok) + } + if _, ok := list.AccordionAt(1); ok { + t.Fatal("AccordionAt matched past a collapsed block's single header row") + } +} + +// TestAccordionAtKeepsExpandedDetailRowsAfterTheHeaderScrollsOff: an +// expanded block's header is not the hit-test anchor, so scrolling it out of +// view must not blind AccordionAt to the disclosed rows still on screen. +func TestAccordionAtKeepsExpandedDetailRowsAfterTheHeaderScrollsOff(t *testing.T) { + list := listWithTool(agent.ToolSuccess) + list.ToggleDisclosure(list.view[0].id) list.SetHeight(1) - list.ScrollBy(1) - if _, ok := list.HeaderAt(0); ok { - t.Fatal("HeaderAt matched after the header scrolled off the top") + list.ScrollBy(1) // scroll past the header; no chevron remains visible + + if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { + t.Fatalf("AccordionAt(0) = %v, %t, want the block even with its header scrolled off", id, ok) } } @@ -861,6 +880,44 @@ func TestHoverPaintsOnlyTheSurfaceNotTheDocument(t *testing.T) { } } +// TestHoverPaintsTheWholeExpandedBlock: once a block is expanded, every row +// it renders is part of the single click target, so hovering any row of it +// must paint the header's stronger fill on row 0 and the softer detail fill +// on every row below it — never the detail fill on the header, and never +// either fill past the item's last row. +func TestHoverPaintsTheWholeExpandedBlock(t *testing.T) { + const headerFill = "48;2;34;36;43" // dark accordionHover (#22242B) + const detailFill = "48;2;28;30;38" // dark accordionDetailHover (#1C1E26) + + list := listWithTool(agent.ToolSuccess) + list.ToggleDisclosure(list.view[0].id) + list.ScrollToTop() + height := list.itemHeight(0) + + assertWholeBlockPainted := func(t *testing.T) { + t.Helper() + lines := strings.Split(list.Render(), "\n") + for row := range height { + want := detailFill + if row == 0 { + want = headerFill + } + if !strings.Contains(lines[row], want) { + t.Fatalf("row %d missing its hover fill %q:\n%s", row, want, lines[row]) + } + } + if after := lines[height]; strings.Contains(after, headerFill) || strings.Contains(after, detailFill) { + t.Fatalf("hover leaked past the item's last row (%d) into row %d:\n%s", height-1, height, after) + } + } + + list.SetPointerRow(0) + assertWholeBlockPainted(t) + + list.SetPointerRow(height - 1) + assertWholeBlockPainted(t) +} + // TestToggleAllDisclosure: the first ctrl+o expands, the next collapses, and // each press discards individual toggles. func TestToggleAllDisclosure(t *testing.T) { diff --git a/internal/tui/components/accordion.go b/internal/tui/components/accordion.go index 10980adc..96fb4aab 100644 --- a/internal/tui/components/accordion.go +++ b/internal/tui/components/accordion.go @@ -7,8 +7,9 @@ import ( ) // Accordion renders the disclosure control for a block's detail rows. The -// caller owns the expanded state and the click target; hover is painted by the -// caller across the whole row (see PaintRowBackground). +// caller owns the expanded state and the click target — the whole disclosed +// region once expanded, not just this row; hover is painted by the caller +// across each of those rows (see PaintRowBackground). func Accordion(sty styles.Accordion, expanded bool) string { glyph := sty.Collapsed if expanded { diff --git a/internal/tui/model.go b/internal/tui/model.go index 307183ea..1536d78a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -263,7 +263,8 @@ type Model struct { follow followControl // pendingAccordionToggle is the block a mouse-down landed on inside an - // accordion header row. Every left-click also begins a potential + // accordion zone — the header row always, plus every disclosed row once + // the block is expanded. Every left-click also begins a potential // drag-select (see beginSelection), so the toggle stays pending until // finishSelection sees the gesture through: it fires only if the release // never turned it into a real selection range. diff --git a/internal/tui/styles/builder.go b/internal/tui/styles/builder.go index 7c41149f..8283f068 100644 --- a/internal/tui/styles/builder.go +++ b/internal/tui/styles/builder.go @@ -118,7 +118,8 @@ func build(isDark bool, p palette) Theme { Foreground(lipgloss.Color(p.textSecondary)). Background(lipgloss.Color(p.accordionBackground)). Padding(0, 1), - HoverBackground: lipgloss.Color(p.accordionHover), + HoverBackground: lipgloss.Color(p.accordionHover), + DetailHoverBackground: lipgloss.Color(p.accordionDetailHover), } selector := Selector{ Item: text.Primary, diff --git a/internal/tui/styles/palette.go b/internal/tui/styles/palette.go index 7aeaa7d3..cbf85c4b 100644 --- a/internal/tui/styles/palette.go +++ b/internal/tui/styles/palette.go @@ -37,6 +37,7 @@ type palette struct { // ANSI-256 index rather than a hex value that reads well as a fill; light // just reuses surfaceRaised, which already sits one step above surface. accordionHover string + accordionDetailHover string // accordionBackground is the disclosure control's resting fill. Fixed to // the same hex in both palettes by design request, rather than tuned per @@ -95,6 +96,7 @@ func darkPalette() palette { codeSurface: "#343336", codeText: "#CECECE", accordionHover: "#22242B", + accordionDetailHover: "#1C1E26", accordionBackground: "#22242B", sweepHot: "#A2C6FF", link: "#3d8bd0", @@ -138,6 +140,7 @@ func lightPalette() palette { codeSurface: "#CED3DD", codeText: "#1C2E38", accordionHover: "#BDC5D3", + accordionDetailHover: "#D7DCE5", accordionBackground: "#22242B", sweepHot: "#A2C6FF", link: "#006bc2", diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index c058058d..1c49d8be 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -180,11 +180,17 @@ type Accordion struct { Resting lipgloss.Style // HoverBackground is painted across the whole header row, not just the - // control's own cells, when that row is hovered. The caller (chat.List) - // repaints an already-rendered line's background cell-by-cell rather than - // wrapping it in a lipgloss style, since the line's own foreground colors - // must survive the repaint. + // control's own cells, when the header row itself is hovered. The caller + // (chat.List) repaints an already-rendered line's background cell-by-cell + // rather than wrapping it in a lipgloss style, since the line's own + // foreground colors must survive the repaint. HoverBackground color.Color + + // DetailHoverBackground paints every other row of an expanded block once + // any row in its disclosed region is hovered — the whole block is one + // click target, so hover covers it all, but a softer tone than + // HoverBackground keeps the header visually distinct from its content. + DetailHoverBackground color.Color } // Selector styles the reusable two-column keyboard selector. diff --git a/internal/tui/update.go b/internal/tui/update.go index a8c8a8d7..ae18a1db 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -590,7 +590,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) tea.Cmd { if msg.Button == tea.MouseLeft { m.beginLinkClick(msg) // Defer the toggle until release so drag selection can take over. - m.pendingAccordionToggle, m.hasPendingAccordionToggle = m.list.HeaderAt(msg.Y) + m.pendingAccordionToggle, m.hasPendingAccordionToggle = m.list.AccordionAt(msg.Y) return m.beginSelection(msg) } } From 5e45a260ceb0fa762b4dcead001cd3e3d5c30c4c Mon Sep 17 00:00:00 2001 From: Will Su Date: Thu, 15 Oct 2026 21:21:17 -0400 Subject: [PATCH 2/4] docs(tui): tighten comments on the accordion hover change --- internal/tui/accordion_input_test.go | 37 +++++++++------------------- internal/tui/chat/list.go | 22 ++++++----------- internal/tui/chat/list_test.go | 16 +++++------- internal/tui/components/accordion.go | 6 ++--- internal/tui/model.go | 4 +-- internal/tui/styles/palette.go | 4 +++ internal/tui/styles/styles.go | 15 ++++++----- 7 files changed, 42 insertions(+), 62 deletions(-) diff --git a/internal/tui/accordion_input_test.go b/internal/tui/accordion_input_test.go index af165c3f..00e899ae 100644 --- a/internal/tui/accordion_input_test.go +++ b/internal/tui/accordion_input_test.go @@ -186,28 +186,23 @@ func TestMouseMotionSetsPointerShapeOverAnAccordionRow(t *testing.T) { } } -// TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion: the hand -// pointer shape follows List.Hovered, so it covers an expanded block's whole -// disclosed region, not just its header. A collapsed block has no disclosed -// rows, so the row right after its header stays the default shape. +// TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion: the hand shape +// follows List.Hovered, so it covers an expanded block's disclosed rows too. func TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion(t *testing.T) { m := accordionTestModel(t) row := accordionRow(t, m) - // reconcilePointerShape only emits the OSC 22 sequence when the shape - // actually changes (see its doc comment), so establish a known "hand on" - // baseline on the header before checking a transition away from it. + // The OSC 22 sequence is only emitted on a shape change, so establish a + // hand-on baseline before checking the transition away from it. if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row}); rawSequence(cmd) != ansi.SetPointerShape("pointer") { t.Fatal("hovering the header did not set the hand pointer shape") } - // Collapsed: nothing below the header row belongs to the block. if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row + 1}); rawSequence(cmd) != ansi.SetPointerShape("default") { t.Fatal("a collapsed block's body row set the hand pointer shape") } m.list.ToggleAllDisclosure() - // Every disclosed row of accordionTestOutput (5 lines) is now part of the - // one click target, including the last one — and so gets the hand too. + // accordionTestOutput is 5 lines; every one of them is now a target. for offset := 1; offset <= 5; offset++ { m.Update(tea.MouseMotionMsg{X: 0, Y: row + offset}) if !m.list.Hovered() { @@ -215,19 +210,15 @@ func TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion(t *testing.T) { } } - // One row past the expanded block's last disclosed row is outside it, so - // the hand must come back off. if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row + 6}); rawSequence(cmd) != ansi.SetPointerShape("default") { t.Fatal("the row after the expanded block kept the hand pointer shape") } } // scrollableAccordionTestModel builds several identical tool blocks in a -// viewport too short to show them all, so ScrollBy actually moves the -// offset instead of no-oping at AtBottom(). Each block's output is a single -// line, so an expanded block occupies exactly two rows (header + one detail -// row) — small enough that a single wheel notch can cross a block boundary -// inside the 3-row viewport this fixture's window size produces. +// viewport too short to show them all, so ScrollBy actually moves the offset +// instead of no-oping at AtBottom(). Single-line output keeps an expanded +// block at two rows, so one wheel notch crosses a block boundary. func scrollableAccordionTestModel(t *testing.T) *Model { t.Helper() m := newShell() @@ -262,18 +253,14 @@ func visibleAccordionRow(t *testing.T, m *Model) int { return -1 } -// TestMouseWheelScrollRefreshesHoverUnderTheStationaryPointer: every row of -// an expanded block is a hover target, so a wheel notch that slides a -// *different* block under a stationary pointer must retarget hover to that -// block rather than keep reporting the one that scrolled away. Hover stays -// set across the notch; it is the targeted block that changes. +// TestMouseWheelScrollRefreshesHoverUnderTheStationaryPointer: a wheel notch +// that slides a different block under a stationary pointer must retarget +// hover to it. Hover stays set across the notch; the targeted block changes. func TestMouseWheelScrollRefreshesHoverUnderTheStationaryPointer(t *testing.T) { m := scrollableAccordionTestModel(t) m.list.ToggleAllDisclosure() m.list.ScrollToTop() - // One row below the chevron is still inside the first block's expanded - // region (its lone detail row): a stable fixture to point at regardless of - // how the chevron itself happens to line up with the viewport. + // One row below the chevron is the first block's lone detail row. row := visibleAccordionRow(t, m) + 1 before, ok := m.list.AccordionAt(row) diff --git a/internal/tui/chat/list.go b/internal/tui/chat/list.go index 8b0d6a26..514a4049 100644 --- a/internal/tui/chat/list.go +++ b/internal/tui/chat/list.go @@ -382,8 +382,7 @@ func (l *List) HoveredLink() (LinkHit, bool) { return l.LinkAt(l.pointerX, l.poi // AccordionAt returns the block whose clickable accordion zone sits on // viewport row y. A collapsed block's only target is its header row; an -// expanded block's target is every row it renders, since the whole disclosed -// region collapses it back. +// expanded block's is every row it renders. func (l *List) AccordionAt(y int) (agent.BlockID, bool) { idx, _, ok := l.accordionAtRow(y) if !ok { @@ -392,10 +391,8 @@ func (l *List) AccordionAt(y int) (agent.BlockID, bool) { return l.view[idx].id, true } -// accordionAtRow maps a viewport row to the accordion item it targets, if -// any. Row 0 of a guttered item is always a target (the header); later rows -// are targets only once the item is expanded, since a collapsed item's -// compact body rows carry no disclosure semantics. +// accordionAtRow maps a viewport row to the accordion item it targets. Row 0 +// of a guttered item is the header; later rows count only once expanded. func (l *List) accordionAtRow(y int) (idx, line int, ok bool) { idx, line, ok = l.itemAtRow(y) if !ok || l.gutter(l.view[idx]) == 0 { @@ -407,18 +404,15 @@ func (l *List) accordionAtRow(y int) (idx, line int, ok bool) { return idx, line, true } -// hoverFill is the viewport row span an accordion hover paints, split into -// the stronger header tone and the softer detail tone that covers the rest -// of an expanded block. header may be negative when the block's header has -// scrolled above the viewport; start/end are already clamped to [0, height]. +// hoverFill is the row span an accordion hover paints. header is unclamped +// and may be negative once the header scrolls above the viewport; start and +// end are clamped to [0, height]. type hoverFill struct { header, start, end int } -// hoverFillRows returns the rows the pointer's accordion hover should paint. -// The header may have scrolled above row 0, and the item may run past the -// viewport bottom, so both ends are clamped independently of header, which -// stays unclamped to tell the paint loop whether row 0 is itself the header. +// hoverFillRows returns the rows the pointer's accordion hover should paint, +// clamped to the viewport. func (l *List) hoverFillRows() (hoverFill, bool) { idx, line, ok := l.accordionAtRow(l.pointerY) if !ok { diff --git a/internal/tui/chat/list_test.go b/internal/tui/chat/list_test.go index 0cd1d507..d6d6d462 100644 --- a/internal/tui/chat/list_test.go +++ b/internal/tui/chat/list_test.go @@ -816,8 +816,7 @@ func TestDocumentAgreesWithVisibleSurfaceAcrossCollapseAndScroll(t *testing.T) { } // TestAccordionAtCoversTheExpandedBlockAndNothingElse: an expanded block's -// disclosed rows are part of its click target, a collapsed block's compact -// body rows are not, and neither extends past the viewport. +// disclosed rows are targets, a collapsed block's compact body rows are not. func TestAccordionAtCoversTheExpandedBlockAndNothingElse(t *testing.T) { list := listWithTool(agent.ToolSuccess) list.ToggleDisclosure(list.view[0].id) // give the header detail rows @@ -842,9 +841,8 @@ func TestAccordionAtCoversTheExpandedBlockAndNothingElse(t *testing.T) { } } -// TestAccordionAtKeepsExpandedDetailRowsAfterTheHeaderScrollsOff: an -// expanded block's header is not the hit-test anchor, so scrolling it out of -// view must not blind AccordionAt to the disclosed rows still on screen. +// TestAccordionAtKeepsExpandedDetailRowsAfterTheHeaderScrollsOff: the header +// is not the hit-test anchor, so scrolling it off must not hide the rest. func TestAccordionAtKeepsExpandedDetailRowsAfterTheHeaderScrollsOff(t *testing.T) { list := listWithTool(agent.ToolSuccess) list.ToggleDisclosure(list.view[0].id) @@ -880,11 +878,9 @@ func TestHoverPaintsOnlyTheSurfaceNotTheDocument(t *testing.T) { } } -// TestHoverPaintsTheWholeExpandedBlock: once a block is expanded, every row -// it renders is part of the single click target, so hovering any row of it -// must paint the header's stronger fill on row 0 and the softer detail fill -// on every row below it — never the detail fill on the header, and never -// either fill past the item's last row. +// TestHoverPaintsTheWholeExpandedBlock: hovering any row of an expanded block +// paints the header fill on row 0, the detail fill below, and nothing past +// the item's last row. func TestHoverPaintsTheWholeExpandedBlock(t *testing.T) { const headerFill = "48;2;34;36;43" // dark accordionHover (#22242B) const detailFill = "48;2;28;30;38" // dark accordionDetailHover (#1C1E26) diff --git a/internal/tui/components/accordion.go b/internal/tui/components/accordion.go index 96fb4aab..d972d7b5 100644 --- a/internal/tui/components/accordion.go +++ b/internal/tui/components/accordion.go @@ -7,9 +7,9 @@ import ( ) // Accordion renders the disclosure control for a block's detail rows. The -// caller owns the expanded state and the click target — the whole disclosed -// region once expanded, not just this row; hover is painted by the caller -// across each of those rows (see PaintRowBackground). +// caller owns the expanded state and the click target, which once expanded is +// the whole disclosed region, and paints hover across it (see +// PaintRowBackground). func Accordion(sty styles.Accordion, expanded bool) string { glyph := sty.Collapsed if expanded { diff --git a/internal/tui/model.go b/internal/tui/model.go index 1536d78a..4e67c676 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -263,8 +263,8 @@ type Model struct { follow followControl // pendingAccordionToggle is the block a mouse-down landed on inside an - // accordion zone — the header row always, plus every disclosed row once - // the block is expanded. Every left-click also begins a potential + // accordion zone: the header row, plus every disclosed row once the block + // is expanded. Every left-click also begins a potential // drag-select (see beginSelection), so the toggle stays pending until // finishSelection sees the gesture through: it fires only if the release // never turned it into a real selection range. diff --git a/internal/tui/styles/palette.go b/internal/tui/styles/palette.go index cbf85c4b..d4e251f6 100644 --- a/internal/tui/styles/palette.go +++ b/internal/tui/styles/palette.go @@ -37,6 +37,10 @@ type palette struct { // ANSI-256 index rather than a hex value that reads well as a fill; light // just reuses surfaceRaised, which already sits one step above surface. accordionHover string + + // accordionDetailHover is accordionHover blended at 45% over the + // background, used for an expanded block's disclosed rows so the header + // keeps the stronger tone. accordionDetailHover string // accordionBackground is the disclosure control's resting fill. Fixed to diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index 1c49d8be..47455ae4 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -180,16 +180,15 @@ type Accordion struct { Resting lipgloss.Style // HoverBackground is painted across the whole header row, not just the - // control's own cells, when the header row itself is hovered. The caller - // (chat.List) repaints an already-rendered line's background cell-by-cell - // rather than wrapping it in a lipgloss style, since the line's own - // foreground colors must survive the repaint. + // control's own cells, when that row is hovered. The caller (chat.List) + // repaints an already-rendered line's background cell-by-cell rather than + // wrapping it in a lipgloss style, since the line's own foreground colors + // must survive the repaint. HoverBackground color.Color - // DetailHoverBackground paints every other row of an expanded block once - // any row in its disclosed region is hovered — the whole block is one - // click target, so hover covers it all, but a softer tone than - // HoverBackground keeps the header visually distinct from its content. + // DetailHoverBackground paints an expanded block's disclosed rows while + // any row of it is hovered. Softer than HoverBackground so the header + // stays distinct from its content. DetailHoverBackground color.Color } From 235464b2f21f40b7e9e8a96f58dd18d662c2f78f Mon Sep 17 00:00:00 2001 From: Will Su Date: Thu, 15 Oct 2026 21:32:42 -0400 Subject: [PATCH 3/4] fix(tui): restrict collapsed hover painting to the header row hoverFillRows painted a collapsed block's whole compact body even though accordionAtRow only targets its header, so the hover misrepresented the click target for blocks with preview rows (exec_command, edit_file, write_file). Add a regression test using an edit_file diff fixture, since the existing fixture's collapsed body is a single row and hid the bug. --- internal/tui/chat/list.go | 5 ++- internal/tui/chat/list_test.go | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/internal/tui/chat/list.go b/internal/tui/chat/list.go index 514a4049..9bbdc2f0 100644 --- a/internal/tui/chat/list.go +++ b/internal/tui/chat/list.go @@ -419,7 +419,10 @@ func (l *List) hoverFillRows() (hoverFill, bool) { return hoverFill{}, false } header := l.pointerY - line - end := header + len(l.entry(idx).lines) + end := header + 1 + if l.expanded(l.view[idx].id) { + end = header + len(l.entry(idx).lines) + } return hoverFill{header: header, start: max(header, 0), end: min(end, l.height)}, true } diff --git a/internal/tui/chat/list_test.go b/internal/tui/chat/list_test.go index d6d6d462..77909da8 100644 --- a/internal/tui/chat/list_test.go +++ b/internal/tui/chat/list_test.go @@ -9,6 +9,7 @@ import ( "github.com/datadog-labs/bits-cli/internal/agent" "github.com/datadog-labs/bits-cli/internal/assistant" + "github.com/datadog-labs/bits-cli/internal/filediff" "github.com/datadog-labs/bits-cli/internal/tools/spec" "github.com/datadog-labs/bits-cli/internal/tui/components" "github.com/datadog-labs/bits-cli/internal/tui/styles" @@ -914,6 +915,77 @@ func TestHoverPaintsTheWholeExpandedBlock(t *testing.T) { assertWholeBlockPainted(t) } +// listWithEditDiff returns a sized list holding one edit_file block whose +// collapsed view renders a multi-line diff preview below its header. +func listWithEditDiff() *List { + var before, after strings.Builder + for i := 1; i <= 5; i++ { + fmt.Fprintf(&before, "line-%d\n", i) + fmt.Fprintf(&after, "LINE-%d\n", i) + } + diff, display := filediff.BuildWithDisplay("a/f.go", "b/f.go", before.String(), after.String()) + block := editorToolBlock("edit_file", display, &filediff.State{ + Phase: filediff.PhaseApplied, + Change: &filediff.Change{Path: "f.go", Op: filediff.OpEdit, State: filediff.ChangeApplied, Diff: &diff}, + }) + block.ID = agent.BlockID{Scope: agent.ScopeTool, Key: "call-1"} + + list := NewList() + list.SetStyles(DefaultStyles(true)) + list.SetWidth(80) + list.SetHeight(20) + list.SetItems([]agent.Block{block}) + return list +} + +// TestCollapsedPreviewRowsStayOutsideHoverAndClickTarget: a collapsed block +// with compact preview rows (edit_file, write_file, exec_command) must paint +// hover only on its header, matching the collapsed click target. Expanding +// the same block must then paint — and target — the whole region. +func TestCollapsedPreviewRowsStayOutsideHoverAndClickTarget(t *testing.T) { + const headerFill = "48;2;34;36;43" // dark accordionHover (#22242B) + const detailFill = "48;2;28;30;38" // dark accordionDetailHover (#1C1E26) + + list := listWithEditDiff() + list.ScrollToTop() + collapsedHeight := list.itemHeight(0) + if collapsedHeight < 2 { + t.Fatalf("collapsed fixture only rendered %d line(s), want a compact preview body too", collapsedHeight) + } + + list.SetPointerRow(0) + if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { + t.Fatalf("AccordionAt(0) on a collapsed block = %v, %t, want the header", id, ok) + } + if _, ok := list.AccordionAt(1); ok { + t.Fatal("collapsed block's preview row is a click target, want header-only") + } + + lines := strings.Split(list.Render(), "\n") + if !strings.Contains(lines[0], headerFill) { + t.Fatalf("collapsed header row missing its hover fill:\n%s", lines[0]) + } + for row := 1; row < collapsedHeight; row++ { + if strings.Contains(lines[row], headerFill) || strings.Contains(lines[row], detailFill) { + t.Fatalf("collapsed preview row %d painted by header hover, want unpainted:\n%s", row, lines[row]) + } + } + + list.ToggleDisclosure(list.view[0].id) + expandedHeight := list.itemHeight(0) + list.SetPointerRow(0) + lines = strings.Split(list.Render(), "\n") + for row := range expandedHeight { + want := detailFill + if row == 0 { + want = headerFill + } + if !strings.Contains(lines[row], want) { + t.Fatalf("expanded row %d missing its hover fill %q:\n%s", row, want, lines[row]) + } + } +} + // TestToggleAllDisclosure: the first ctrl+o expands, the next collapses, and // each press discards individual toggles. func TestToggleAllDisclosure(t *testing.T) { From 9d711d9d77ef58c90ea9199203a10b46d2ff89b6 Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Fri, 16 Oct 2026 13:27:12 +0200 Subject: [PATCH 4/4] feat(tui): include collapsed preview rows in the accordion zone A collapsed block's compact preview (exec_command output, edit_file and write_file diffs) is now part of its click and hover target, like an expanded block's disclosed rows. The zone is simply every row a guttered block renders, which drops the expanded-state branches from accordionAtRow and hoverFillRows and supersedes the header-only restriction from 780b217b. Consolidate the accordion tests around that single rule: - TestAccordionZoneIsTheWholeRenderedBlock table-tests targeting and hover painting for header-only, preview and expanded blocks, replacing three overlapping tests. - TestAccordionZoneSurvivesTheHeaderScrollingOff now also checks the visible rows keep the detail fill once the header scrolls off. - TestMouseMotionSetsPointerShapeOverTheAccordionZone merges the two pointer-shape tests and asserts real OSC 22 transitions. --- internal/tui/accordion_input_test.go | 52 ++---- internal/tui/chat/list.go | 16 +- internal/tui/chat/list_test.go | 226 ++++++++++++--------------- internal/tui/components/accordion.go | 5 +- internal/tui/model.go | 5 +- internal/tui/styles/palette.go | 138 ++++++++-------- internal/tui/styles/styles.go | 4 +- 7 files changed, 192 insertions(+), 254 deletions(-) diff --git a/internal/tui/accordion_input_test.go b/internal/tui/accordion_input_test.go index 00e899ae..6c8b551a 100644 --- a/internal/tui/accordion_input_test.go +++ b/internal/tui/accordion_input_test.go @@ -171,48 +171,28 @@ func rawSequence(cmd tea.Cmd) string { return "" } -func TestMouseMotionSetsPointerShapeOverAnAccordionRow(t *testing.T) { +// TestMouseMotionSetsPointerShapeOverTheAccordionZone: the hand shape tracks +// the block's whole zone, whether that is a lone collapsed header or every +// disclosed row once expanded, and resets on the first row past it. OSC 22 is +// only emitted on a shape change, so each step alternates the expected shape. +func TestMouseMotionSetsPointerShapeOverTheAccordionZone(t *testing.T) { m := accordionTestModel(t) row := accordionRow(t, m) - - _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row}) - if got := rawSequence(cmd); got != ansi.SetPointerShape("pointer") { - t.Fatalf("hovering the row did not set the pointer shape: got %q, want %q", got, ansi.SetPointerShape("pointer")) - } - - _, cmd = m.Update(tea.MouseMotionMsg{X: 0, Y: row + 1}) - if got := rawSequence(cmd); got != ansi.SetPointerShape("default") { - t.Fatalf("un-hovering the row did not reset the pointer shape: got %q, want %q", got, ansi.SetPointerShape("default")) + hand, arrow := ansi.SetPointerShape("pointer"), ansi.SetPointerShape("default") + expect := func(y int, want, what string) { + t.Helper() + if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: y}); rawSequence(cmd) != want { + t.Fatalf("%s: got %q, want %q", what, rawSequence(cmd), want) + } } -} - -// TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion: the hand shape -// follows List.Hovered, so it covers an expanded block's disclosed rows too. -func TestMouseMotionSetsHandPointerAcrossTheWholeExpandedRegion(t *testing.T) { - m := accordionTestModel(t) - row := accordionRow(t, m) - // The OSC 22 sequence is only emitted on a shape change, so establish a - // hand-on baseline before checking the transition away from it. - if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row}); rawSequence(cmd) != ansi.SetPointerShape("pointer") { - t.Fatal("hovering the header did not set the hand pointer shape") - } - if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row + 1}); rawSequence(cmd) != ansi.SetPointerShape("default") { - t.Fatal("a collapsed block's body row set the hand pointer shape") - } + expect(row, hand, "hovering the collapsed header") + expect(row+1, arrow, "leaving the collapsed header-only block") m.list.ToggleAllDisclosure() - // accordionTestOutput is 5 lines; every one of them is now a target. - for offset := 1; offset <= 5; offset++ { - m.Update(tea.MouseMotionMsg{X: 0, Y: row + offset}) - if !m.list.Hovered() { - t.Fatalf("disclosed row at offset %d did not report hover", offset) - } - } - - if _, cmd := m.Update(tea.MouseMotionMsg{X: 0, Y: row + 6}); rawSequence(cmd) != ansi.SetPointerShape("default") { - t.Fatal("the row after the expanded block kept the hand pointer shape") - } + // accordionTestOutput is 5 lines, so the expanded block spans row..row+5. + expect(row+5, hand, "hovering the last disclosed row") + expect(row+6, arrow, "leaving the expanded block") } // scrollableAccordionTestModel builds several identical tool blocks in a diff --git a/internal/tui/chat/list.go b/internal/tui/chat/list.go index 9bbdc2f0..600954a2 100644 --- a/internal/tui/chat/list.go +++ b/internal/tui/chat/list.go @@ -381,8 +381,8 @@ func (l *List) Hovered() bool { func (l *List) HoveredLink() (LinkHit, bool) { return l.LinkAt(l.pointerX, l.pointerY) } // AccordionAt returns the block whose clickable accordion zone sits on -// viewport row y. A collapsed block's only target is its header row; an -// expanded block's is every row it renders. +// viewport row y: every row an expandable block renders, whether collapsed +// (header plus any compact preview) or expanded. func (l *List) AccordionAt(y int) (agent.BlockID, bool) { idx, _, ok := l.accordionAtRow(y) if !ok { @@ -391,16 +391,13 @@ func (l *List) AccordionAt(y int) (agent.BlockID, bool) { return l.view[idx].id, true } -// accordionAtRow maps a viewport row to the accordion item it targets. Row 0 -// of a guttered item is the header; later rows count only once expanded. +// accordionAtRow maps a viewport row to the guttered item it targets and the +// row's line within it; line 0 is the header. func (l *List) accordionAtRow(y int) (idx, line int, ok bool) { idx, line, ok = l.itemAtRow(y) if !ok || l.gutter(l.view[idx]) == 0 { return 0, 0, false } - if line > 0 && !l.expanded(l.view[idx].id) { - return 0, 0, false - } return idx, line, true } @@ -419,10 +416,7 @@ func (l *List) hoverFillRows() (hoverFill, bool) { return hoverFill{}, false } header := l.pointerY - line - end := header + 1 - if l.expanded(l.view[idx].id) { - end = header + len(l.entry(idx).lines) - } + end := header + len(l.entry(idx).lines) return hoverFill{header: header, start: max(header, 0), end: min(end, l.height)}, true } diff --git a/internal/tui/chat/list_test.go b/internal/tui/chat/list_test.go index 77909da8..41bb80b4 100644 --- a/internal/tui/chat/list_test.go +++ b/internal/tui/chat/list_test.go @@ -816,103 +816,14 @@ func TestDocumentAgreesWithVisibleSurfaceAcrossCollapseAndScroll(t *testing.T) { } } -// TestAccordionAtCoversTheExpandedBlockAndNothingElse: an expanded block's -// disclosed rows are targets, a collapsed block's compact body rows are not. -func TestAccordionAtCoversTheExpandedBlockAndNothingElse(t *testing.T) { - list := listWithTool(agent.ToolSuccess) - list.ToggleDisclosure(list.view[0].id) // give the header detail rows - list.ScrollToTop() // drop follow so the header starts visible - list.SetHeight(2) - if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { - t.Fatalf("AccordionAt(0) = %v, %t, want the tool's header", id, ok) - } - if id, ok := list.AccordionAt(1); !ok || id != list.view[0].id { - t.Fatalf("AccordionAt(1) = %v, %t, want the tool's disclosed detail row", id, ok) - } - if _, ok := list.AccordionAt(2); ok { - t.Fatal("AccordionAt matched a row outside the viewport") - } - - list.ToggleDisclosure(list.view[0].id) // collapse again - if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { - t.Fatalf("AccordionAt(0) = %v, %t, want the collapsed block's header", id, ok) - } - if _, ok := list.AccordionAt(1); ok { - t.Fatal("AccordionAt matched past a collapsed block's single header row") - } -} - -// TestAccordionAtKeepsExpandedDetailRowsAfterTheHeaderScrollsOff: the header -// is not the hit-test anchor, so scrolling it off must not hide the rest. -func TestAccordionAtKeepsExpandedDetailRowsAfterTheHeaderScrollsOff(t *testing.T) { - list := listWithTool(agent.ToolSuccess) - list.ToggleDisclosure(list.view[0].id) - list.SetHeight(1) - list.ScrollBy(1) // scroll past the header; no chevron remains visible - - if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { - t.Fatalf("AccordionAt(0) = %v, %t, want the block even with its header scrolled off", id, ok) - } -} - -func TestHoverPaintsOnlyTheSurfaceNotTheDocument(t *testing.T) { - list := listWithTool(agent.ToolSuccess) - list.ScrollToTop() - resting := list.Render() - document := list.Document() - height := list.itemHeight(0) - - list.SetPointerRow(0) - if !list.Hovered() { - t.Fatal("pointer over the header did not hover it") - } - if list.Render() == resting { - t.Fatal("hover did not change the rendered surface") - } - if list.Document() != document || list.itemHeight(0) != height { - t.Fatal("hover leaked into the document or the item height") - } - - list.SetPointerRow(1) - if list.Hovered() || list.Render() != resting { - t.Fatal("moving off the header row did not clear hover") - } -} - -// TestHoverPaintsTheWholeExpandedBlock: hovering any row of an expanded block -// paints the header fill on row 0, the detail fill below, and nothing past -// the item's last row. -func TestHoverPaintsTheWholeExpandedBlock(t *testing.T) { - const headerFill = "48;2;34;36;43" // dark accordionHover (#22242B) - const detailFill = "48;2;28;30;38" // dark accordionDetailHover (#1C1E26) - - list := listWithTool(agent.ToolSuccess) - list.ToggleDisclosure(list.view[0].id) - list.ScrollToTop() - height := list.itemHeight(0) - - assertWholeBlockPainted := func(t *testing.T) { - t.Helper() - lines := strings.Split(list.Render(), "\n") - for row := range height { - want := detailFill - if row == 0 { - want = headerFill - } - if !strings.Contains(lines[row], want) { - t.Fatalf("row %d missing its hover fill %q:\n%s", row, want, lines[row]) - } - } - if after := lines[height]; strings.Contains(after, headerFill) || strings.Contains(after, detailFill) { - t.Fatalf("hover leaked past the item's last row (%d) into row %d:\n%s", height-1, height, after) - } - } - - list.SetPointerRow(0) - assertWholeBlockPainted(t) +// Dark-theme hover fills, as SGR background parameters. +const ( + headerHoverFill = "48;2;34;36;43" // accordionHover (#22242B) + detailHoverFill = "48;2;28;30;38" // accordionDetailHover (#1C1E26) +) - list.SetPointerRow(height - 1) - assertWholeBlockPainted(t) +func hasHoverFill(line string) bool { + return strings.Contains(line, headerHoverFill) || strings.Contains(line, detailHoverFill) } // listWithEditDiff returns a sized list holding one edit_file block whose @@ -938,51 +849,106 @@ func listWithEditDiff() *List { return list } -// TestCollapsedPreviewRowsStayOutsideHoverAndClickTarget: a collapsed block -// with compact preview rows (edit_file, write_file, exec_command) must paint -// hover only on its header, matching the collapsed click target. Expanding -// the same block must then paint — and target — the whole region. -func TestCollapsedPreviewRowsStayOutsideHoverAndClickTarget(t *testing.T) { - const headerFill = "48;2;34;36;43" // dark accordionHover (#22242B) - const detailFill = "48;2;28;30;38" // dark accordionDetailHover (#1C1E26) +// TestAccordionZoneIsTheWholeRenderedBlock: whatever a guttered block renders +// (a lone header, a collapsed preview such as exec_command or edit_file, or +// the full disclosure) is both its click target and its hover region. From +// any of its rows, hover paints the header fill on row 0 and the detail fill +// below, and neither targeting nor painting reaches past its last row. +func TestAccordionZoneIsTheWholeRenderedBlock(t *testing.T) { + for _, tc := range []struct { + name string + list func() *List + expanded bool + }{ + {"collapsed header only", func() *List { return listWithTool(agent.ToolSuccess) }, false}, + {"expanded detail", func() *List { return listWithTool(agent.ToolSuccess) }, true}, + {"collapsed preview", listWithEditDiff, false}, + {"expanded preview", listWithEditDiff, true}, + } { + t.Run(tc.name, func(t *testing.T) { + list := tc.list() + if tc.expanded { + list.ToggleDisclosure(list.view[0].id) + } + list.ScrollToTop() + height := list.itemHeight(0) + if height >= list.height { + t.Fatalf("fixture is %d rows in a %d-row viewport, want room below it", height, list.height) + } - list := listWithEditDiff() - list.ScrollToTop() - collapsedHeight := list.itemHeight(0) - if collapsedHeight < 2 { - t.Fatalf("collapsed fixture only rendered %d line(s), want a compact preview body too", collapsedHeight) + for row := range height { + if id, ok := list.AccordionAt(row); !ok || id != list.view[0].id { + t.Fatalf("AccordionAt(%d) = %v, %t, want the block", row, id, ok) + } + } + if _, ok := list.AccordionAt(height); ok { + t.Fatalf("AccordionAt(%d) matched past the block's last row", height) + } + + for _, pointer := range []int{0, height - 1} { + list.SetPointerRow(pointer) + lines := strings.Split(list.Render(), "\n") + for row := range height { + want := detailHoverFill + if row == 0 { + want = headerHoverFill + } + if !strings.Contains(lines[row], want) { + t.Fatalf("pointer on row %d: row %d missing its hover fill %q:\n%s", pointer, row, want, lines[row]) + } + } + if hasHoverFill(lines[height]) { + t.Fatalf("pointer on row %d: hover leaked past the block into row %d:\n%s", pointer, height, lines[height]) + } + } + }) } +} + +// TestAccordionZoneSurvivesTheHeaderScrollingOff: the header is not the +// hit-test anchor, so scrolling it off must keep the rest targeted, and the +// rows left on screen keep the detail fill rather than taking the header's. +func TestAccordionZoneSurvivesTheHeaderScrollingOff(t *testing.T) { + list := listWithTool(agent.ToolSuccess) + list.ToggleDisclosure(list.view[0].id) + list.SetHeight(1) + list.ScrollBy(1) // scroll past the header; no chevron remains visible - list.SetPointerRow(0) if id, ok := list.AccordionAt(0); !ok || id != list.view[0].id { - t.Fatalf("AccordionAt(0) on a collapsed block = %v, %t, want the header", id, ok) + t.Fatalf("AccordionAt(0) = %v, %t, want the block even with its header scrolled off", id, ok) } if _, ok := list.AccordionAt(1); ok { - t.Fatal("collapsed block's preview row is a click target, want header-only") + t.Fatal("AccordionAt matched a row outside the viewport") } - lines := strings.Split(list.Render(), "\n") - if !strings.Contains(lines[0], headerFill) { - t.Fatalf("collapsed header row missing its hover fill:\n%s", lines[0]) - } - for row := 1; row < collapsedHeight; row++ { - if strings.Contains(lines[row], headerFill) || strings.Contains(lines[row], detailFill) { - t.Fatalf("collapsed preview row %d painted by header hover, want unpainted:\n%s", row, lines[row]) - } + list.SetPointerRow(0) + line := list.Render() + if !strings.Contains(line, detailHoverFill) || strings.Contains(line, headerHoverFill) { + t.Fatalf("visible detail row not painted with the detail fill alone:\n%s", line) } +} + +func TestHoverPaintsOnlyTheSurfaceNotTheDocument(t *testing.T) { + list := listWithTool(agent.ToolSuccess) + list.ScrollToTop() + resting := list.Render() + document := list.Document() + height := list.itemHeight(0) - list.ToggleDisclosure(list.view[0].id) - expandedHeight := list.itemHeight(0) list.SetPointerRow(0) - lines = strings.Split(list.Render(), "\n") - for row := range expandedHeight { - want := detailFill - if row == 0 { - want = headerFill - } - if !strings.Contains(lines[row], want) { - t.Fatalf("expanded row %d missing its hover fill %q:\n%s", row, want, lines[row]) - } + if !list.Hovered() { + t.Fatal("pointer over the header did not hover it") + } + if list.Render() == resting { + t.Fatal("hover did not change the rendered surface") + } + if list.Document() != document || list.itemHeight(0) != height { + t.Fatal("hover leaked into the document or the item height") + } + + list.SetPointerRow(1) + if list.Hovered() || list.Render() != resting { + t.Fatal("moving off the header row did not clear hover") } } diff --git a/internal/tui/components/accordion.go b/internal/tui/components/accordion.go index d972d7b5..25f63997 100644 --- a/internal/tui/components/accordion.go +++ b/internal/tui/components/accordion.go @@ -7,9 +7,8 @@ import ( ) // Accordion renders the disclosure control for a block's detail rows. The -// caller owns the expanded state and the click target, which once expanded is -// the whole disclosed region, and paints hover across it (see -// PaintRowBackground). +// caller owns the expanded state and the click target, which is every row the +// block renders, and paints hover across it (see PaintRowBackground). func Accordion(sty styles.Accordion, expanded bool) string { glyph := sty.Collapsed if expanded { diff --git a/internal/tui/model.go b/internal/tui/model.go index 4e67c676..89da93b9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -263,9 +263,8 @@ type Model struct { follow followControl // pendingAccordionToggle is the block a mouse-down landed on inside an - // accordion zone: the header row, plus every disclosed row once the block - // is expanded. Every left-click also begins a potential - // drag-select (see beginSelection), so the toggle stays pending until + // accordion zone: any row an expandable block renders, collapsed preview + // rows included. Every left-click also begins a potential drag-select (see beginSelection), so the toggle stays pending until // finishSelection sees the gesture through: it fires only if the release // never turned it into a real selection range. pendingAccordionToggle agent.BlockID diff --git a/internal/tui/styles/palette.go b/internal/tui/styles/palette.go index d4e251f6..5c5807a7 100644 --- a/internal/tui/styles/palette.go +++ b/internal/tui/styles/palette.go @@ -39,7 +39,7 @@ type palette struct { accordionHover string // accordionDetailHover is accordionHover blended at 45% over the - // background, used for an expanded block's disclosed rows so the header + // background, used for a hovered block's rows below its header so the header // keeps the stronger tone. accordionDetailHover string @@ -83,41 +83,41 @@ type palette struct { func darkPalette() palette { return palette{ - primary: "#5e6dd6", - interactive: "#8B80F9", - secondary: "#3f4ca5", - onAccent: "#FFFFFF", - onWarning: "#1A1A1A", - textPrimary: "#FFFFFF", - textSecondary: "#A2A3A6", - textTertiary: "#45474D", - background: "#171921", - surface: "#22252F", - surfaceRaised: "236", - approvalSurface: "#2C3142", - inputRule: "#383A40", - borderSubtle: "#474A54", - codeSurface: "#343336", - codeText: "#CECECE", - accordionHover: "#22242B", + primary: "#5e6dd6", + interactive: "#8B80F9", + secondary: "#3f4ca5", + onAccent: "#FFFFFF", + onWarning: "#1A1A1A", + textPrimary: "#FFFFFF", + textSecondary: "#A2A3A6", + textTertiary: "#45474D", + background: "#171921", + surface: "#22252F", + surfaceRaised: "236", + approvalSurface: "#2C3142", + inputRule: "#383A40", + borderSubtle: "#474A54", + codeSurface: "#343336", + codeText: "#CECECE", + accordionHover: "#22242B", accordionDetailHover: "#1C1E26", - accordionBackground: "#22242B", - sweepHot: "#A2C6FF", - link: "#3d8bd0", - busy: "#F5C453", - busySurface: "#2E2409", - busyDim: "#8A6A1F", - busyHot: "#FFE9A8", - success: "#349C50", - successSurface: "#0A2F10", - error: "#D33043", - errorSurface: "#2F0A0F", - feedbackSuccess: "#65A875", - feedbackError: "#D77480", - critical: "#C4314B", - info: "#632CA6", - warning: "#F5A623", - fullAccessSurface: "#812836", + accordionBackground: "#22242B", + sweepHot: "#A2C6FF", + link: "#3d8bd0", + busy: "#F5C453", + busySurface: "#2E2409", + busyDim: "#8A6A1F", + busyHot: "#FFE9A8", + success: "#349C50", + successSurface: "#0A2F10", + error: "#D33043", + errorSurface: "#2F0A0F", + feedbackSuccess: "#65A875", + feedbackError: "#D77480", + critical: "#C4314B", + info: "#632CA6", + warning: "#F5A623", + fullAccessSurface: "#812836", } } @@ -127,40 +127,40 @@ func darkPalette() palette { // The three text levels are picked by eye; see the palette struct. func lightPalette() palette { return palette{ - primary: "#5e6dd6", - interactive: "#3F4991", - secondary: "#1d2140", - onAccent: "#FFFFFF", - onWarning: "#1A1A1A", - textPrimary: "#000000", - textSecondary: "#5E5F62", - textTertiary: "#989BA0", - background: "#ECEFF4", - surface: "#CCD3DF", - surfaceRaised: "#BDC5D3", - approvalSurface: "#B7C1D5", - inputRule: "#383A40", - borderSubtle: "#9AA3B2", - codeSurface: "#CED3DD", - codeText: "#1C2E38", - accordionHover: "#BDC5D3", + primary: "#5e6dd6", + interactive: "#3F4991", + secondary: "#1d2140", + onAccent: "#FFFFFF", + onWarning: "#1A1A1A", + textPrimary: "#000000", + textSecondary: "#5E5F62", + textTertiary: "#989BA0", + background: "#ECEFF4", + surface: "#CCD3DF", + surfaceRaised: "#BDC5D3", + approvalSurface: "#B7C1D5", + inputRule: "#383A40", + borderSubtle: "#9AA3B2", + codeSurface: "#CED3DD", + codeText: "#1C2E38", + accordionHover: "#BDC5D3", accordionDetailHover: "#D7DCE5", - accordionBackground: "#22242B", - sweepHot: "#A2C6FF", - link: "#006bc2", - busy: "#7A5200", - busySurface: "#E5D198", - busyDim: "#D2A340", - busyHot: "#4A3000", - success: "#41C464", - successSurface: "#EAFDED", - error: "#B61225", - errorSurface: "#F9C6CC", - feedbackSuccess: "#30663E", - feedbackError: "#A13443", - critical: "#C4314B", - info: "#632CA6", - warning: "#F5A623", - fullAccessSurface: "#EC93A0", + accordionBackground: "#22242B", + sweepHot: "#A2C6FF", + link: "#006bc2", + busy: "#7A5200", + busySurface: "#E5D198", + busyDim: "#D2A340", + busyHot: "#4A3000", + success: "#41C464", + successSurface: "#EAFDED", + error: "#B61225", + errorSurface: "#F9C6CC", + feedbackSuccess: "#30663E", + feedbackError: "#A13443", + critical: "#C4314B", + info: "#632CA6", + warning: "#F5A623", + fullAccessSurface: "#EC93A0", } } diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index 47455ae4..e9e8b8d7 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -186,8 +186,8 @@ type Accordion struct { // must survive the repaint. HoverBackground color.Color - // DetailHoverBackground paints an expanded block's disclosed rows while - // any row of it is hovered. Softer than HoverBackground so the header + // DetailHoverBackground paints a block's rows below its header (compact + // preview or full disclosure) while any row of it is hovered. Softer than HoverBackground so the header // stays distinct from its content. DetailHoverBackground color.Color }