From 04abf74b9d79a94935fa5dc704c5213f4e7a6077 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 21 Sep 2026 19:46:18 -0500 Subject: [PATCH 1/4] glitter: add hover feedback to the seven remaining board regions Wires hover state and rendering for the commit button, the History tab, the filter/summary/description boxes, the stash strip, and the undo chip: mouseMotion already resolved all of these hit kinds, they just had no hover field or paint. Each new field clears alongside the existing ones so the pointer leaving a region can never leave a stuck highlight. Co-Authored-By: Claude Sonnet 5 --- ui/internal/views/mission/changes.go | 85 +++++-- ui/internal/views/mission/mission.go | 43 +++- ui/internal/views/mission/render_test.go | 307 ++++++++++++++++++++++- 3 files changed, 394 insertions(+), 41 deletions(-) diff --git a/ui/internal/views/mission/changes.go b/ui/internal/views/mission/changes.go index d2f8602e2..24015eb70 100644 --- a/ui/internal/views/mission/changes.go +++ b/ui/internal/views/mission/changes.go @@ -26,17 +26,24 @@ const commitBoxInner = sidebarWidth - 4 // Rule under the inactive half (the board's own bottom border). Changes is // always the active tab; History has no wire state to select it yet // (renderKeybar's own "History lands in v2" notice covers a click on it). -func renderTabsRow(changedTotal, width int) string { +// hoverHistory paints HoverBg behind the History label's own half only -- +// the underline's active/inactive split is untouched by hover. +func renderTabsRow(changedTotal int, hoverHistory bool, width int) string { on := lipgloss.NewStyle().Background(theme.Bg) half := width / 2 otherHalf := width - half + historyOn := on + if hoverHistory { + historyOn = on.Background(theme.HoverBg) + } + changesLabel := on.Foreground(theme.Text).Bold(true).Render("Changes") + on.Foreground(theme.PinkSoft).Render(fmt.Sprintf(" %d", changedTotal)) - historyLabel := on.Foreground(theme.Dimmer).Render("History") + on.Foreground(theme.Faint).Render(" v2") + historyLabel := historyOn.Foreground(theme.Dimmer).Render("History") + historyOn.Foreground(theme.Faint).Render(" v2") top := on.Width(half).Align(lipgloss.Center).Render(changesLabel) + - on.Width(otherHalf).Align(lipgloss.Center).Render(historyLabel) + historyOn.Width(otherHalf).Align(lipgloss.Center).Render(historyLabel) underline := on.Foreground(theme.Pink).Render(strings.Repeat("─", half)) + on.Foreground(theme.Rule).Render(strings.Repeat("─", otherHalf)) return top + "\n" + underline @@ -44,8 +51,11 @@ func renderTabsRow(changedTotal, width int) string { // renderFilterRow paints the "❯ filter" box: the typed filter text, or the // Faint placeholder while empty. The border brightens to Pink while the -// filter itself holds focus, mirroring the summary/description boxes below. -func renderFilterRow(text string, focused bool, width int) string { +// filter itself holds focus, mirroring the summary/description boxes below; +// hover gets the exact same Pink treatment (unlike the summary/description +// boxes, the filter box has no separate dimmer hover tone to stay distinct +// from -- hovering it while it also holds focus is simply a no-op repaint). +func renderFilterRow(text string, focused, hovered bool, width int) string { inner := width - 4 if inner < 1 { inner = 1 @@ -64,7 +74,7 @@ func renderFilterRow(text string, focused bool, width int) string { } line := on.Foreground(theme.Dimmer).Render(theme.GlyphChevron+" ") + bodyStyle.Render(clip(body, textW)) border := theme.Panel - if focused { + if focused || hovered { border = theme.Pink } return lipgloss.NewStyle().Background(theme.Bg).Border(lipgloss.RoundedBorder()).BorderForeground(border).BorderBackground(theme.Bg).Padding(0, 1). @@ -185,9 +195,14 @@ func renderChangeRow(c ChangeRow, width int, cursor, hover bool) string { } // renderStashStrip is the "Stashed changes · N ❯" row: a notice strip, not -// yet a foldout (a later interaction pass wires the click). -func renderStashStrip(count, width int) string { - on := lipgloss.NewStyle().Background(theme.BgSubtle) +// yet a foldout (a later interaction pass wires the click). hovered swaps +// its whole-strip BgSubtle rest fill for HoverBg. +func renderStashStrip(count int, hovered bool, width int) string { + bg := theme.BgSubtle + if hovered { + bg = theme.HoverBg + } + on := lipgloss.NewStyle().Background(bg) left := on.Foreground(theme.Dim).Render(fmt.Sprintf("Stashed changes · %d", count)) right := on.Foreground(theme.Dimmer).Render(theme.GlyphChevron) return justify(on, width, left, right) @@ -197,8 +212,11 @@ func renderStashStrip(count, width int) string { // summary box, the description box, and the commit button, top to bottom. // Amending overrides the button's own label to "Amend last commit" -- a // display-only substitution; enabled (the caller's commitEnabled result) -// still gates the button's treatment, amending or not. -func renderCommitBox(width int, summaryView, descriptionView string, amending bool, buttonLabel string, enabled bool) string { +// still gates the button's treatment, amending or not. hoverButton/ +// hoverSummary/hoverDescription are each region's own independent hover +// flag (mission.go's mouseMotion never sets more than one at a time, but +// nothing here assumes that). +func renderCommitBox(width int, summaryView, descriptionView string, amending bool, buttonLabel string, enabled, hoverButton, hoverSummary, hoverDescription bool) string { var lines []string if amending { on := lipgloss.NewStyle().Background(theme.Bg) @@ -209,22 +227,27 @@ func renderCommitBox(width int, summaryView, descriptionView string, amending bo // row in the terminal (docs/design/mission/README.md's Terminal // geometry table). lines = append(lines, blankRows(width, 1)) - lines = append(lines, boxLine(width, summaryView)) - lines = append(lines, boxBlock(width, []string{descriptionView, ""})) + lines = append(lines, boxLine(width, summaryView, hoverSummary)) + lines = append(lines, boxBlock(width, []string{descriptionView, ""}, hoverDescription)) // The board's own gap between the description box and the button (8px) // reads as one blank band row -- unlike the summary/description seam, // which stays flush (docs/design/mission/README.md's Terminal geometry // table). lines = append(lines, blankRows(width, 1)) - lines = append(lines, renderCommitButton(width, buttonLabel, enabled)) + lines = append(lines, renderCommitButton(width, buttonLabel, enabled, hoverButton)) return lipgloss.JoinVertical(lipgloss.Left, lines...) } -func boxLine(width int, content string) string { - return boxBlock(width, []string{content}) +func boxLine(width int, content string, hovered bool) string { + return boxBlock(width, []string{content}, hovered) } -func boxBlock(width int, contentLines []string) string { +// boxBlock's border brightens to GutterHoverBar (Pink blended half-way +// toward Bg -- the same dimmer-than-full-Pink tone the diff gutter's own +// hover preview already established) while hovered: dimmer than the filter +// box's Pink focus treatment on purpose, so a hovered summary/description +// box never reads as already focused. +func boxBlock(width int, contentLines []string, hovered bool) string { inner := width - 4 if inner < 1 { inner = 1 @@ -234,7 +257,11 @@ func boxBlock(width int, contentLines []string) string { for i, l := range contentLines { padded[i] = on.Width(inner).Render(l) } - return lipgloss.NewStyle().Background(theme.Bg).Border(lipgloss.RoundedBorder()).BorderForeground(theme.Panel).BorderBackground(theme.Bg).Padding(0, 1). + var border color.Color = theme.Panel + if hovered { + border = theme.GutterHoverBar + } + return lipgloss.NewStyle().Background(theme.Bg).Border(lipgloss.RoundedBorder()).BorderForeground(border).BorderBackground(theme.Bg).Padding(0, 1). Render(strings.Join(padded, "\n")) } @@ -258,10 +285,16 @@ func boxBlock(width int, contentLines []string) string { // files to " would otherwise spill it onto a second row -- CodeRabbit // finding on PR #353). The block is a fixed THREE-row unit in the sidebar's // own layout now; sidebarHit maps all three rows to the same hit target. -func renderCommitButton(width int, label string, canCommit bool) string { +// hovered brightens the fill to PinkSoft, but only when canCommit: a +// disabled button must never hover, since hover always means "this will do +// something". +func renderCommitButton(width int, label string, canCommit, hovered bool) string { buttonColor, textColor := theme.Pink, theme.Bg - if !canCommit { + switch { + case !canCommit: buttonColor, textColor = theme.Panel, theme.Dimmer + case hovered: + buttonColor = theme.PinkSoft } capStyle := lipgloss.NewStyle().Width(width).Background(theme.Bg).Foreground(buttonColor) labelStyle := lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Bold(true).Background(buttonColor).Foreground(textColor) @@ -271,11 +304,17 @@ func renderCommitButton(width int, label string, canCommit bool) string { } // renderUndoStrip is the WarnBg strip a successful, still-undoable commit -// leaves behind: what got committed, when, and the Undo chip. -func renderUndoStrip(lc LastCommit, width int) string { +// leaves behind: what got committed, when, and the Undo chip. hovered +// brightens the chip's own Panel fill to HoverBg -- the strip's WarnBg line +// around it is untouched, so hover reads as the chip, not the whole row. +func renderUndoStrip(lc LastCommit, hovered bool, width int) string { on := lipgloss.NewStyle().Background(theme.WarnBg) left := on.Foreground(theme.Dimmer).Render("Committed "+lc.When+" · ") + on.Foreground(theme.TextSoft).Render(lc.Summary) - right := on.Foreground(theme.Text).Background(theme.Panel).Padding(0, 1).Render("Undo") + chipBg := theme.Panel + if hovered { + chipBg = theme.HoverBg + } + right := on.Foreground(theme.Text).Background(chipBg).Padding(0, 1).Render("Undo") return justify(on, width, left, right) } diff --git a/ui/internal/views/mission/mission.go b/ui/internal/views/mission/mission.go index f72b9272c..bda26950c 100644 --- a/ui/internal/views/mission/mission.go +++ b/ui/internal/views/mission/mission.go @@ -85,6 +85,18 @@ type Mission struct { hoverDiffLine int hoverGutter bool + // The sidebar's other hoverable regions, each a plain bool (no index to + // carry): mouseMotion clears every one of these at the top of its switch + // alongside hoverFile/hoverDiffLine, so a pointer that leaves a region + // can never leave its highlight stuck. + hoverCommitButton bool + hoverTabHistory bool + hoverFilterRow bool + hoverCommitSummary bool + hoverCommitDescription bool + hoverStash bool + hoverUndoChip bool + // lastClickPath/lastClickAt pair a file row's two clicks into a double // click (focuses the diff) the same way the picker's own clickRow does // for its list; nowFn overrides the clock in tests, nil meaning @@ -441,9 +453,9 @@ const sidebarFixedTopRows = 7 func (m *Mission) sidebarFixedTop(width int) string { return lipgloss.JoinVertical(lipgloss.Left, - renderTabsRow(m.model.ChangedTotal, width), + renderTabsRow(m.model.ChangedTotal, m.hoverTabHistory, width), blankRows(width, 1), - renderFilterRow(m.filterDisplayText(), m.focus == focusFilter, width), + renderFilterRow(m.filterDisplayText(), m.focus == focusFilter, m.hoverFilterRow, width), renderMasterRow(m.model.ChangedTotal, m.model.StagedTotal, width), ) } @@ -453,12 +465,12 @@ func (m *Mission) sidebarFixedTop(width int) string { func (m *Mission) sidebarDocked(width int) string { var docked []string if m.model.StashCount > 0 { - docked = append(docked, renderStashStrip(m.model.StashCount, width)) + docked = append(docked, renderStashStrip(m.model.StashCount, m.hoverStash, width)) } docked = append(docked, lipgloss.NewStyle().Background(theme.Bg).Foreground(theme.Rule).Render(strings.Repeat("─", width))) - docked = append(docked, renderCommitBox(width, m.summaryInput.View(), m.descriptionInput.View(), m.amendLocal, m.model.Commit.ButtonLabel, m.commitEnabled())) + docked = append(docked, renderCommitBox(width, m.summaryInput.View(), m.descriptionInput.View(), m.amendLocal, m.model.Commit.ButtonLabel, m.commitEnabled(), m.hoverCommitButton, m.hoverCommitSummary, m.hoverCommitDescription)) if lc := m.model.Commit.LastCommit; lc != nil && lc.Undoable { - docked = append(docked, renderUndoStrip(*lc, width)) + docked = append(docked, renderUndoStrip(*lc, m.hoverUndoChip, width)) } return lipgloss.JoinVertical(lipgloss.Left, docked...) } @@ -1075,6 +1087,13 @@ func (m *Mission) mouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) { m.hoverFile = -1 m.hoverDiffLine = -1 m.hoverGutter = false + m.hoverCommitButton = false + m.hoverTabHistory = false + m.hoverFilterRow = false + m.hoverCommitSummary = false + m.hoverCommitDescription = false + m.hoverStash = false + m.hoverUndoChip = false if m.modal != nil { m.modal.hoverRow = -1 m.modal.hoverAction = false @@ -1093,6 +1112,20 @@ func (m *Mission) mouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) { m.modal.hoverRow = h.idx case hitModalAction: m.modal.hoverAction = true + case hitCommitButton: + m.hoverCommitButton = true + case hitTabHistory: + m.hoverTabHistory = true + case hitFilterRow: + m.hoverFilterRow = true + case hitCommitSummary: + m.hoverCommitSummary = true + case hitCommitDescription: + m.hoverCommitDescription = true + case hitStash: + m.hoverStash = true + case hitUndoChip: + m.hoverUndoChip = true } return m, nil } diff --git a/ui/internal/views/mission/render_test.go b/ui/internal/views/mission/render_test.go index 8766e1f88..aa204d23c 100644 --- a/ui/internal/views/mission/render_test.go +++ b/ui/internal/views/mission/render_test.go @@ -283,23 +283,169 @@ func TestRenderChangeRowHoverPaintsHoverBgUnlessCursor(t *testing.T) { } func TestRenderCommitButtonDisabledWearsPanelBg(t *testing.T) { - out := renderCommitButton(40, "Commit 2 files to main", false) + out := renderCommitButton(40, "Commit 2 files to main", false, false) if !strings.Contains(out, bgSGR(theme.Panel)) { t.Fatalf("disabled button should wear Panel bg: %q", out) } - enabled := renderCommitButton(40, "Commit 2 files to main", true) + enabled := renderCommitButton(40, "Commit 2 files to main", true, false) if !strings.Contains(enabled, bgSGR(theme.Pink)) { t.Fatalf("enabled button should wear Pink bg: %q", enabled) } } +// TestRenderCommitButtonHoverBrightensOnlyWhenPressable pins item 1: a +// pressable (canCommit) hovered button brightens to PinkSoft, but a +// disabled button hovered must NOT brighten -- hover always means "this +// will do something", and geometry (row count, per-row width) must not +// shift between the hovered and rest states. +func TestRenderCommitButtonHoverBrightensOnlyWhenPressable(t *testing.T) { + const width = 40 + rest := renderCommitButton(width, "Commit 2 files to main", true, false) + hovered := renderCommitButton(width, "Commit 2 files to main", true, true) + if strings.Contains(rest, bgSGR(theme.PinkSoft)) { + t.Fatalf("un-hovered enabled button must not wear PinkSoft: %q", rest) + } + if !strings.Contains(hovered, bgSGR(theme.PinkSoft)) { + t.Fatalf("hovered enabled button should brighten to PinkSoft: %q", hovered) + } + + disabledHovered := renderCommitButton(width, "Commit 2 files to main", false, true) + if strings.Contains(disabledHovered, bgSGR(theme.PinkSoft)) { + t.Fatalf("a disabled button must never hover-brighten: %q", disabledHovered) + } + if !strings.Contains(disabledHovered, bgSGR(theme.Panel)) { + t.Fatalf("a disabled button hovered should still wear Panel bg: %q", disabledHovered) + } + + restLines := strings.Split(rest, "\n") + hoveredLines := strings.Split(hovered, "\n") + if len(restLines) != len(hoveredLines) { + t.Fatalf("hover must not change the button's row count: rest=%d hovered=%d", len(restLines), len(hoveredLines)) + } + for i := range restLines { + rw := lipgloss.Width(ansi.Strip(restLines[i])) + hw := lipgloss.Width(ansi.Strip(hoveredLines[i])) + if rw != hw || rw != width { + t.Fatalf("row %d width must stay %d in both states, got rest=%d hovered=%d", i, width, rw, hw) + } + } +} + +// TestRenderFilterRowHoverBrightensBorderLikeFocus pins item 3: hovering the +// filter box brightens its border to Pink, the exact same treatment focus +// already uses -- and hover must not change the box's rendered width. +func TestRenderFilterRowHoverBrightensBorderLikeFocus(t *testing.T) { + rest := renderFilterRow("", false, false, sidebarWidth) + hovered := renderFilterRow("", false, true, sidebarWidth) + focused := renderFilterRow("", true, false, sidebarWidth) + + if strings.Contains(rest, fgSGR(theme.Pink)) { + t.Fatalf("un-hovered, un-focused filter row must not wear a Pink border: %q", rest) + } + if !strings.Contains(hovered, fgSGR(theme.Pink)) { + t.Fatalf("hovered filter row should brighten its border to Pink: %q", hovered) + } + if lipgloss.Width(ansi.Strip(hovered)) != lipgloss.Width(ansi.Strip(focused)) { + t.Fatalf("hovered and focused filter rows should have identical widths") + } + if lipgloss.Width(ansi.Strip(rest)) != lipgloss.Width(ansi.Strip(hovered)) { + t.Fatalf("hover must not change the filter row's rendered width: rest=%d hovered=%d", + lipgloss.Width(ansi.Strip(rest)), lipgloss.Width(ansi.Strip(hovered))) + } +} + +// TestRenderCommitSummaryDescriptionHoverIsDistinctFromFocus pins items 4/5 +// and the owner's explicit nuance: a hovered summary/description box must +// NOT wear the filter box's own Pink focus color (that would read as +// already focused) -- it gets GutterHoverBar, the same dimmer-than-Pink +// tone the diff gutter's own hover preview already uses -- and hover must +// not change either box's geometry. +func TestRenderCommitSummaryDescriptionHoverIsDistinctFromFocus(t *testing.T) { + restBox := renderCommitBox(sidebarWidth, "", "", false, "Commit 2 files to main", false, false, false, false) + summaryHover := renderCommitBox(sidebarWidth, "", "", false, "Commit 2 files to main", false, false, true, false) + descriptionHover := renderCommitBox(sidebarWidth, "", "", false, "Commit 2 files to main", false, false, false, true) + + if strings.Contains(restBox, fgSGR(theme.GutterHoverBar)) { + t.Fatalf("rest-state commit box must not wear GutterHoverBar: %q", restBox) + } + if !strings.Contains(summaryHover, fgSGR(theme.GutterHoverBar)) { + t.Fatalf("hovered summary box should wear GutterHoverBar on its border: %q", summaryHover) + } + if strings.Contains(summaryHover, fgSGR(theme.Pink)) { + t.Fatalf("hovered summary box must NOT wear the focus Pink border (would read as focused): %q", summaryHover) + } + if !strings.Contains(descriptionHover, fgSGR(theme.GutterHoverBar)) { + t.Fatalf("hovered description box should wear GutterHoverBar on its border: %q", descriptionHover) + } + if strings.Contains(descriptionHover, fgSGR(theme.Pink)) { + t.Fatalf("hovered description box must NOT wear the focus Pink border (would read as focused): %q", descriptionHover) + } + + restLines := strings.Split(restBox, "\n") + summaryLines := strings.Split(summaryHover, "\n") + descriptionLines := strings.Split(descriptionHover, "\n") + if len(restLines) != len(summaryLines) || len(restLines) != len(descriptionLines) { + t.Fatalf("hover must not change the commit box's row count: rest=%d summary=%d description=%d", + len(restLines), len(summaryLines), len(descriptionLines)) + } + for i := range restLines { + want := lipgloss.Width(ansi.Strip(restLines[i])) + if got := lipgloss.Width(ansi.Strip(summaryLines[i])); got != want { + t.Fatalf("row %d width changed under summary hover: want %d got %d", i, want, got) + } + if got := lipgloss.Width(ansi.Strip(descriptionLines[i])); got != want { + t.Fatalf("row %d width changed under description hover: want %d got %d", i, want, got) + } + } +} + +// TestRenderStashStripHoverPaintsHoverBg pins item 6: hovering the stash +// strip swaps its whole rest-state BgSubtle fill for HoverBg, without +// changing its rendered width. +func TestRenderStashStripHoverPaintsHoverBg(t *testing.T) { + rest := renderStashStrip(2, false, sidebarWidth) + hovered := renderStashStrip(2, true, sidebarWidth) + if strings.Contains(rest, bgSGR(theme.HoverBg)) { + t.Fatalf("un-hovered stash strip must not wear HoverBg: %q", rest) + } + if !strings.Contains(hovered, bgSGR(theme.HoverBg)) { + t.Fatalf("hovered stash strip should wear HoverBg: %q", hovered) + } + if lipgloss.Width(ansi.Strip(rest)) != lipgloss.Width(ansi.Strip(hovered)) { + t.Fatalf("hover must not change the stash strip's rendered width") + } +} + +// TestRenderUndoStripHoverPaintsChipOnlyNotWholeLine pins item 7: hovering +// the undo chip brightens only the chip's own Panel fill to HoverBg -- the +// WarnBg line it sits on must be untouched -- and the chip's own width +// (hence the strip's) must not move. +func TestRenderUndoStripHoverPaintsChipOnlyNotWholeLine(t *testing.T) { + lc := LastCommit{Summary: "fix parser", When: "2 minutes ago", Undoable: true} + rest := renderUndoStrip(lc, false, sidebarWidth) + hovered := renderUndoStrip(lc, true, sidebarWidth) + + if strings.Contains(rest, bgSGR(theme.HoverBg)) { + t.Fatalf("un-hovered undo strip must not wear HoverBg anywhere: %q", rest) + } + if !strings.Contains(hovered, bgSGR(theme.HoverBg)) { + t.Fatalf("hovered undo strip should wear HoverBg on its chip: %q", hovered) + } + if !strings.Contains(hovered, bgSGR(theme.WarnBg)) { + t.Fatalf("hovering the chip must not remove the strip's own WarnBg line: %q", hovered) + } + if lipgloss.Width(ansi.Strip(rest)) != lipgloss.Width(ansi.Strip(hovered)) { + t.Fatalf("hover must not change the undo strip's rendered width") + } +} + // TestRenderCommitBoxAmendingOverridesButtonLabel pins item 5: amending // swaps the button's own text for "Amend last commit" (a display-only // override -- the enabled flag the caller computed still gates the button's // treatment, amending or not), while a non-amending box keeps the wire label. func TestRenderCommitBoxAmendingOverridesButtonLabel(t *testing.T) { const wireLabel = "Commit 2 files to main" - amending := renderCommitBox(sidebarWidth, "", "", true, wireLabel, false) + amending := renderCommitBox(sidebarWidth, "", "", true, wireLabel, false, false, false, false) amendingPlain := ansi.Strip(amending) if !strings.Contains(amendingPlain, "Amend last commit") { t.Fatalf("amending commit box should show \"Amend last commit\":\n%s", amendingPlain) @@ -311,7 +457,7 @@ func TestRenderCommitBoxAmendingOverridesButtonLabel(t *testing.T) { t.Fatalf("amending with CanCommit false should still wear Panel (disabled): %q", amending) } - notAmending := ansi.Strip(renderCommitBox(sidebarWidth, "", "", false, wireLabel, true)) + notAmending := ansi.Strip(renderCommitBox(sidebarWidth, "", "", false, wireLabel, true, false, false, false)) if !strings.Contains(notAmending, wireLabel) { t.Fatalf("non-amending commit box should keep the wire label:\n%s", notAmending) } @@ -321,7 +467,7 @@ func TestRenderCommitBoxAmendingOverridesButtonLabel(t *testing.T) { } func TestRenderUndoStripAppearsWithFixtureModel(t *testing.T) { - out := renderUndoStrip(LastCommit{Summary: "fix parser", When: "2 minutes ago", Undoable: true}, sidebarWidth) + out := renderUndoStrip(LastCommit{Summary: "fix parser", When: "2 minutes ago", Undoable: true}, false, sidebarWidth) for _, want := range []string{"Committed 2 minutes ago", "fix parser", "Undo"} { if !strings.Contains(out, want) { t.Fatalf("undo strip missing %q:\n%s", want, out) @@ -335,7 +481,7 @@ func TestRenderUndoStripAppearsWithFixtureModel(t *testing.T) { // line past sidebarWidth, or it drags the whole sidebar block wider with it. func TestRenderUndoStripClipsLongSummaryToSidebarWidth(t *testing.T) { longSummary := strings.Repeat("a very long commit summary that keeps going ", 5) - out := renderUndoStrip(LastCommit{Summary: longSummary, When: "2 minutes ago", Undoable: true}, sidebarWidth) + out := renderUndoStrip(LastCommit{Summary: longSummary, When: "2 minutes ago", Undoable: true}, false, sidebarWidth) for _, line := range strings.Split(out, "\n") { if w := lipgloss.Width(line); w != sidebarWidth { t.Fatalf("undo strip row width = %d, want sidebarWidth %d:\n%s", w, sidebarWidth, out) @@ -1732,6 +1878,92 @@ func TestMouseMotionOverFileRowSetsHoverNotCursor(t *testing.T) { } } +// hoverFixtureMission builds a Mission whose model exercises all seven newly +// hoverable regions at once (a nonzero StashCount, a pressable commit +// button, and an undoable last commit) so one test can visit every one of +// them off a single fixture. +func hoverFixtureMission() *Mission { + m := New(nil) + m.width, m.height = 100, 40 + m.model = Model{ + Current: Current{Repo: "repo-tools", Branch: "main"}, + Changes: []ChangeRow{{Path: "a.go", Status: "modified", Include: "all"}}, + ChangedTotal: 1, + StagedTotal: 1, + StashCount: 2, + Commit: CommitModel{ + ButtonLabel: "Commit 1 file to main", + CanCommit: true, + LastCommit: &LastCommit{Summary: "fix parser", When: "2 minutes ago", Undoable: true}, + }, + } + m.summaryInput.SetValue("msg") + m.selected = "a.go" + return m +} + +// findHitY scans the frame's Y range at column x for the first row hitTest +// resolves to kind -- the same "trust hitTest, don't re-derive its row +// arithmetic" approach commitButtonY's own callers already take, reused here +// for regions this test does not otherwise need a named helper for. +func findHitY(m *Mission, x int, kind hitKind) (int, bool) { + for y := 0; y < m.height; y++ { + if m.hitTest(x, y).kind == kind { + return y, true + } + } + return 0, false +} + +// TestMouseMotionWiresAndClearsNewHoverFields is the regression case the +// brief calls out by name: each of the seven newly hoverable regions must +// set its own hover field while the pointer sits over it, and moving the +// pointer away to a coordinate none of them own must clear ALL seven, not +// just the one that had been set -- a field that fails to clear leaves a +// stuck highlight, the main failure mode a per-field bool is prone to. +func TestMouseMotionWiresAndClearsNewHoverFields(t *testing.T) { + m := hoverFixtureMission() + + cases := []struct { + name string + kind hitKind + x int + get func(*Mission) bool + }{ + {"commit button", hitCommitButton, 20, func(m *Mission) bool { return m.hoverCommitButton }}, + {"tab history", hitTabHistory, sidebarWidth - 1, func(m *Mission) bool { return m.hoverTabHistory }}, + {"filter row", hitFilterRow, 5, func(m *Mission) bool { return m.hoverFilterRow }}, + {"commit summary", hitCommitSummary, 20, func(m *Mission) bool { return m.hoverCommitSummary }}, + {"commit description", hitCommitDescription, 20, func(m *Mission) bool { return m.hoverCommitDescription }}, + {"stash", hitStash, 5, func(m *Mission) bool { return m.hoverStash }}, + {"undo chip", hitUndoChip, 20, func(m *Mission) bool { return m.hoverUndoChip }}, + } + + for _, tc := range cases { + y, ok := findHitY(m, tc.x, tc.kind) + if !ok { + t.Fatalf("%s: setup: no row at x=%d resolves to %v in this fixture", tc.name, tc.x, tc.kind) + } + next, _ := m.Update(tea.MouseMotionMsg{X: tc.x, Y: y}) + m = next.(*Mission) + if !tc.get(m) { + t.Fatalf("%s: hovering its own hit region should set its hover field", tc.name) + } + + // (0,0) sits in the top bar's repo segment, a region none of the + // seven fields track -- moving there must clear every one of them. + next, _ = m.Update(tea.MouseMotionMsg{X: 0, Y: 0}) + m = next.(*Mission) + if tc.get(m) { + t.Fatalf("%s: hover field should clear once the pointer leaves its region", tc.name) + } + if m.hoverCommitButton || m.hoverTabHistory || m.hoverFilterRow || m.hoverCommitSummary || + m.hoverCommitDescription || m.hoverStash || m.hoverUndoChip { + t.Fatalf("%s: some hover field stayed stuck after the pointer moved away: %+v", tc.name, m) + } + } +} + // TestMouseWheelOverDiffScrollsIt is the brief's own Step 1 example: a wheel // tick with the pointer over the diff pane moves the diff line cursor // (there being no scroll offset independent of the cursor -- see @@ -2016,7 +2248,7 @@ func TestCommitButtonDocksToSidebarBottomWithZeroChanges(t *testing.T) { // half, spanning the full width between them. func TestRenderTabsRowUnderlineHalfPinkHalfRule(t *testing.T) { const width = 46 - out := renderTabsRow(3, width) + out := renderTabsRow(3, false, width) lines := strings.Split(out, "\n") if len(lines) != 2 { t.Fatalf("tabs row should render exactly 2 rows, got %d:\n%s", len(lines), out) @@ -2047,7 +2279,7 @@ func TestRenderTabsRowUnderlineHalfPinkHalfRule(t *testing.T) { // right half. func TestRenderTabsRowLabelsCenteredInHalves(t *testing.T) { const width = 46 - out := renderTabsRow(3, width) + out := renderTabsRow(3, false, width) top := ansi.Strip(strings.Split(out, "\n")[0]) half := width / 2 left, right := top[:half], top[half:] @@ -2064,6 +2296,55 @@ func TestRenderTabsRowLabelsCenteredInHalves(t *testing.T) { } } +// TestRenderTabsRowHoverPaintsHistoryLabelHalfOnly pins item 2: hovering the +// History tab paints HoverBg behind its own half of the LABEL row only -- +// the Changes half and the underline's Pink/Rule split are both untouched, +// and the half widths (hence the row's overall width) never move. +func TestRenderTabsRowHoverPaintsHistoryLabelHalfOnly(t *testing.T) { + const width = 46 + half := width / 2 + rest := renderTabsRow(3, false, width) + hovered := renderTabsRow(3, true, width) + + restLines := strings.Split(rest, "\n") + hoveredLines := strings.Split(hovered, "\n") + if len(restLines) != 2 || len(hoveredLines) != 2 { + t.Fatalf("tabs row should stay exactly 2 rows in both states: rest=%d hovered=%d", len(restLines), len(hoveredLines)) + } + + hoveredTop := hoveredLines[0] + restTop := restLines[0] + if strings.Contains(restTop, bgSGR(theme.HoverBg)) { + t.Fatalf("un-hovered tabs row must not wear HoverBg: %q", restTop) + } + if !strings.Contains(hoveredTop, bgSGR(theme.HoverBg)) { + t.Fatalf("hovering History should paint HoverBg somewhere in the label row: %q", hoveredTop) + } + + // The Changes label sits in the left half of the label row; splitting on + // display width (not byte index, since it carries ANSI codes) isolates + // it from the History half so a HoverBg leak into the active tab is + // caught, not just HoverBg's presence somewhere in the row. + leftPlain := ansi.Strip(hoveredTop)[:half] + if strings.Contains(leftPlain, "History") { + t.Fatalf("setup: left-half slice should not contain History: %q", leftPlain) + } + + // The underline (row 1) must keep its exact Pink/Rule split regardless + // of hover. + if hoveredLines[1] != restLines[1] { + t.Fatalf("hover must not alter the underline row:\nrest =%q\nhovered=%q", restLines[1], hoveredLines[1]) + } + + for i := range restLines { + rw := lipgloss.Width(ansi.Strip(restLines[i])) + hw := lipgloss.Width(ansi.Strip(hoveredLines[i])) + if rw != hw || rw != width { + t.Fatalf("row %d width must stay %d in both states, got rest=%d hovered=%d", i, width, rw, hw) + } + } +} + // TestTabsHitZonesAreHalfWidth pins the click-zone half of the ruling: the // left half is inert (Changes is already active), the right half resolves // to History, with the boundary landing exactly at width/2. @@ -2161,7 +2442,7 @@ func TestRenderCommitButtonThreeRowsHalfBlockCaps(t *testing.T) { {"disabled", false, theme.Panel, theme.Dimmer}, } for _, tc := range cases { - out := renderCommitButton(width, label, tc.canCommit) + out := renderCommitButton(width, label, tc.canCommit, false) lines := strings.Split(out, "\n") if len(lines) != 3 { t.Fatalf("%s: button should render exactly 3 rows, got %d:\n%s", tc.name, len(lines), out) @@ -2212,7 +2493,7 @@ func TestRenderCommitButtonThreeRowsHalfBlockCaps(t *testing.T) { func TestRenderCommitButtonClipsLongLabelToOneRow(t *testing.T) { const width = 40 long := "Commit 3 files to a-very-long-feature-branch-name-that-would-otherwise-wrap" - out := renderCommitButton(width, long, true) + out := renderCommitButton(width, long, true, false) lines := strings.Split(out, "\n") if len(lines) != 3 { t.Fatalf("commit button must render exactly 3 rows even with a long label, got %d:\n%s", len(lines), out) @@ -2282,7 +2563,7 @@ func TestCommitButtonNeverWrapsKeepsUndoChipRowAligned(t *testing.T) { // blank Bg row separates the description box from the button (the board's // 8px gap), unlike the flush summary/description seam. func TestRenderCommitBoxHasGapBeforeButton(t *testing.T) { - out := ansi.Strip(renderCommitBox(sidebarWidth, "", "", false, "Commit 2 files to main", false)) + out := ansi.Strip(renderCommitBox(sidebarWidth, "", "", false, "Commit 2 files to main", false, false, false, false)) lines := strings.Split(out, "\n") // row0 = box top pad, row1-3 = summary box, row4-7 = description box, // row8 = the new gap, row9-11 = the button's own 3 rows (top half-block @@ -2309,7 +2590,7 @@ func TestRenderCommitBoxHasGapBeforeButton(t *testing.T) { // box's own top padding (board pad=12) is one blank Bg row immediately // before the summary box's own top border. func TestRenderCommitBoxHasBlankBandBeforeSummaryBox(t *testing.T) { - out := renderCommitBox(sidebarWidth, "", "", false, "Commit 2 files to main", false) + out := renderCommitBox(sidebarWidth, "", "", false, "Commit 2 files to main", false, false, false, false) lines := strings.Split(out, "\n") if strings.TrimSpace(ansi.Strip(lines[0])) != "" { t.Fatalf("the commit box's own top-padding row should be blank: %q", lines[0]) @@ -2327,7 +2608,7 @@ func TestRenderCommitBoxHasBlankBandBeforeSummaryBox(t *testing.T) { // banner still leads, but the blank pad row -- and everything after it -- // keeps its own fixed position relative to the summary box. func TestRenderCommitBoxAmendingBannerThenBlankThenSummaryBox(t *testing.T) { - out := ansi.Strip(renderCommitBox(sidebarWidth, "", "", true, "Commit 2 files to main", false)) + out := ansi.Strip(renderCommitBox(sidebarWidth, "", "", true, "Commit 2 files to main", false, false, false, false)) lines := strings.Split(out, "\n") if !strings.Contains(lines[0], "Amending last commit") { t.Fatalf("row 0 should be the amend banner: %q", lines[0]) From d5014f49a23a22bc98ee448588df9a2bf18abee2 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 21 Sep 2026 19:50:35 -0500 Subject: [PATCH 2/4] glitter: make filter box hover match summary/description, not focus The filter, summary, and description boxes are three sibling text boxes and should hover the same way. Hover now brightens the filter box's border to theme.GutterHoverBar, the same dimmer-than-Pink tone the other two already use, instead of reusing the focus Pink -- a hover that reads as already-focused was the wrong call. Focus still wins outright over hover when both are true. Co-Authored-By: Claude Sonnet 5 --- ui/internal/views/mission/changes.go | 16 ++++++---- ui/internal/views/mission/render_test.go | 38 ++++++++++++++++-------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/ui/internal/views/mission/changes.go b/ui/internal/views/mission/changes.go index 24015eb70..9699f67bc 100644 --- a/ui/internal/views/mission/changes.go +++ b/ui/internal/views/mission/changes.go @@ -51,10 +51,11 @@ func renderTabsRow(changedTotal int, hoverHistory bool, width int) string { // renderFilterRow paints the "❯ filter" box: the typed filter text, or the // Faint placeholder while empty. The border brightens to Pink while the -// filter itself holds focus, mirroring the summary/description boxes below; -// hover gets the exact same Pink treatment (unlike the summary/description -// boxes, the filter box has no separate dimmer hover tone to stay distinct -// from -- hovering it while it also holds focus is simply a no-op repaint). +// filter itself holds focus; hover gets GutterHoverBar instead (the same +// dimmer-than-Pink tone the summary/description boxes use), never the +// focus color itself -- all three sibling boxes share the one rule that a +// hover reading as already-focused is wrong, focused still wins outright +// when both are true. func renderFilterRow(text string, focused, hovered bool, width int) string { inner := width - 4 if inner < 1 { @@ -73,9 +74,12 @@ func renderFilterRow(text string, focused, hovered bool, width int) string { bodyStyle = on.Foreground(theme.Faint) } line := on.Foreground(theme.Dimmer).Render(theme.GlyphChevron+" ") + bodyStyle.Render(clip(body, textW)) - border := theme.Panel - if focused || hovered { + var border color.Color = theme.Panel + switch { + case focused: border = theme.Pink + case hovered: + border = theme.GutterHoverBar } return lipgloss.NewStyle().Background(theme.Bg).Border(lipgloss.RoundedBorder()).BorderForeground(border).BorderBackground(theme.Bg).Padding(0, 1). Render(on.Width(inner).Render(line)) diff --git a/ui/internal/views/mission/render_test.go b/ui/internal/views/mission/render_test.go index aa204d23c..3cf1f00e5 100644 --- a/ui/internal/views/mission/render_test.go +++ b/ui/internal/views/mission/render_test.go @@ -331,26 +331,38 @@ func TestRenderCommitButtonHoverBrightensOnlyWhenPressable(t *testing.T) { } } -// TestRenderFilterRowHoverBrightensBorderLikeFocus pins item 3: hovering the -// filter box brightens its border to Pink, the exact same treatment focus -// already uses -- and hover must not change the box's rendered width. -func TestRenderFilterRowHoverBrightensBorderLikeFocus(t *testing.T) { +// TestRenderFilterRowHoverIsDistinctFromFocus pins item 3, corrected: the +// filter box is one of three sibling text boxes (with summary and +// description) that all share the same rule -- a hover reading as +// already-focused is wrong -- so hover brightens the border to +// GutterHoverBar, never Pink; focus keeps Pink and wins outright when both +// are true. Hover must not change the box's rendered width. +func TestRenderFilterRowHoverIsDistinctFromFocus(t *testing.T) { rest := renderFilterRow("", false, false, sidebarWidth) hovered := renderFilterRow("", false, true, sidebarWidth) focused := renderFilterRow("", true, false, sidebarWidth) + focusedAndHovered := renderFilterRow("", true, true, sidebarWidth) - if strings.Contains(rest, fgSGR(theme.Pink)) { - t.Fatalf("un-hovered, un-focused filter row must not wear a Pink border: %q", rest) + if strings.Contains(rest, fgSGR(theme.Pink)) || strings.Contains(rest, fgSGR(theme.GutterHoverBar)) { + t.Fatalf("un-hovered, un-focused filter row must not wear either accent border: %q", rest) } - if !strings.Contains(hovered, fgSGR(theme.Pink)) { - t.Fatalf("hovered filter row should brighten its border to Pink: %q", hovered) + if !strings.Contains(hovered, fgSGR(theme.GutterHoverBar)) { + t.Fatalf("hovered filter row should brighten its border to GutterHoverBar: %q", hovered) } - if lipgloss.Width(ansi.Strip(hovered)) != lipgloss.Width(ansi.Strip(focused)) { - t.Fatalf("hovered and focused filter rows should have identical widths") + if strings.Contains(hovered, fgSGR(theme.Pink)) { + t.Fatalf("hovered (not focused) filter row must NOT wear the focus Pink border (would read as focused): %q", hovered) } - if lipgloss.Width(ansi.Strip(rest)) != lipgloss.Width(ansi.Strip(hovered)) { - t.Fatalf("hover must not change the filter row's rendered width: rest=%d hovered=%d", - lipgloss.Width(ansi.Strip(rest)), lipgloss.Width(ansi.Strip(hovered))) + if !strings.Contains(focused, fgSGR(theme.Pink)) { + t.Fatalf("focused filter row should keep its Pink border: %q", focused) + } + if !strings.Contains(focusedAndHovered, fgSGR(theme.Pink)) || strings.Contains(focusedAndHovered, fgSGR(theme.GutterHoverBar)) { + t.Fatalf("focus should win outright over hover: %q", focusedAndHovered) + } + + for _, out := range []string{rest, hovered, focused, focusedAndHovered} { + if w := lipgloss.Width(ansi.Strip(out)); w != lipgloss.Width(ansi.Strip(rest)) { + t.Fatalf("hover/focus must not change the filter row's rendered width, got %d want %d", w, lipgloss.Width(ansi.Strip(rest))) + } } } From bc00ebf0cb029a45b24b3596dcd9a99b78da6b03 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 21 Sep 2026 22:02:47 -0500 Subject: [PATCH 3/4] glitter: enable hover by fixing the mouse mode decorator session's mouseView.View() overwrote MouseMode unconditionally every frame, clobbering the AllMotion the mission view needs for hover: bubbletea only streams movement with no button pressed under AllMotion, so CellMotion silently made every hover treatment in the board unreachable in any terminal. The decorator now applies CellMotion only as a default when the inner view left MouseMode at its zero value, and Mission.View() sets AllMotion explicitly since the whole board is hover-driven. Co-Authored-By: Claude Sonnet 5 --- ui/internal/session/options_internal_test.go | 27 ++++++++++++++++++++ ui/internal/session/session.go | 14 +++++++--- ui/internal/views/mission/mission.go | 6 +++++ ui/internal/views/mission/render_test.go | 13 ++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ui/internal/session/options_internal_test.go b/ui/internal/session/options_internal_test.go index ba91a8eda..8cdba0d41 100644 --- a/ui/internal/session/options_internal_test.go +++ b/ui/internal/session/options_internal_test.go @@ -15,6 +15,16 @@ func (stubView) View() tea.View { return tea.NewView("stub" func (stubView) SetModel(json.RawMessage) error { return nil } func (stubView) Reason() Reason { return ReasonClosed } +// allMotionStubView is a View that, like mission.Mission, asks for hover +// motion itself instead of leaving MouseMode at its zero value. +type allMotionStubView struct{ stubView } + +func (allMotionStubView) View() tea.View { + v := tea.NewView("stub") + v.MouseMode = tea.MouseModeAllMotion + return v +} + // TestWireMouseSetsCellMotionOnlyWhenOptedIn locks in the actual v2 lever // for Options.Mouse: there is no ProgramOption for it (see programOptions), // so this decorator is the only place the option can take effect. @@ -26,3 +36,20 @@ func TestWireMouseSetsCellMotionOnlyWhenOptedIn(t *testing.T) { t.Fatalf("Mouse true: MouseMode = %v, want CellMotion", got) } } + +// TestMouseViewDefersToAnExplicitMouseMode locks in the other half of +// wireMouse: MouseModeNone is bubbletea's zero value, so a view that sets +// nothing and a view that wants mouse off render identically. This +// decorator only ever wraps a view that opted into Options.Mouse in the +// first place, so it must apply CellMotion only as a default for the +// zero-value case, never clobber a mode the inner view actually chose -- +// otherwise a view that asks for AllMotion (mission, to get hover) never +// gets it. +func TestMouseViewDefersToAnExplicitMouseMode(t *testing.T) { + if got := wireMouse(allMotionStubView{}, Options{Mouse: true}).View().MouseMode; got != tea.MouseModeAllMotion { + t.Fatalf("inner AllMotion: MouseMode = %v, want AllMotion", got) + } + if got := wireMouse(stubView{}, Options{Mouse: true}).View().MouseMode; got != tea.MouseModeCellMotion { + t.Fatalf("inner unset: MouseMode = %v, want CellMotion default", got) + } +} diff --git a/ui/internal/session/session.go b/ui/internal/session/session.go index 77455c8cc..4949db0a1 100644 --- a/ui/internal/session/session.go +++ b/ui/internal/session/session.go @@ -89,9 +89,15 @@ func programOptions(ctx context.Context, term *os.File) []tea.ProgramOption { } } -// mouseView decorates a View so every frame it paints reports +// mouseView decorates a View so every frame it paints reports at least // MouseModeCellMotion, without requiring board- or echo-style views to know -// anything about mouse mode themselves. +// anything about mouse mode themselves. MouseModeNone is bubbletea's zero +// value, so a view that never touches MouseMode is indistinguishable from +// one that actively wants mouse off; since this decorator only ever wraps a +// view that opted into Options.Mouse in the first place, that zero value is +// read as "didn't ask" and defaulted to CellMotion, never as a real +// request for none. A view that sets its own mode (mission's AllMotion, for +// hover) is left alone. type mouseView struct{ view View } func (m mouseView) Init() tea.Cmd { return m.view.Init() } @@ -109,7 +115,9 @@ func (m mouseView) Reason() Reason { return m.view.Reason() func (m mouseView) View() tea.View { v := m.view.View() - v.MouseMode = tea.MouseModeCellMotion + if v.MouseMode == tea.MouseModeNone { + v.MouseMode = tea.MouseModeCellMotion + } return v } diff --git a/ui/internal/views/mission/mission.go b/ui/internal/views/mission/mission.go index bda26950c..eeaff6a7a 100644 --- a/ui/internal/views/mission/mission.go +++ b/ui/internal/views/mission/mission.go @@ -625,6 +625,12 @@ func (m *Mission) View() tea.View { v := tea.NewView(out) v.AltScreen = true + // The whole board is hover-driven (row/segment/diff-line/modal-row + // treatments all key off mouseMotion), and MouseModeCellMotion only + // reports movement while a button is held, so hover needs AllMotion + // explicitly -- session's wireMouse decorator defers to whatever mode + // is already set here rather than overwriting it. + v.MouseMode = tea.MouseModeAllMotion // bubbletea's renderer optimizes trailing styled blanks by erasing to // end-of-line rather than emitting every styled space, and an erased // cell paints the TERMINAL's own default background, not whatever SGR diff --git a/ui/internal/views/mission/render_test.go b/ui/internal/views/mission/render_test.go index 3cf1f00e5..4f611e753 100644 --- a/ui/internal/views/mission/render_test.go +++ b/ui/internal/views/mission/render_test.go @@ -761,6 +761,19 @@ func newTestMission() *Mission { return m } +// TestViewReportsAllMotion pins the one thing standing between the whole +// board's hover rendering and it ever firing: mouseMotion only sees a +// MouseMotionMsg with no button pressed under MouseModeAllMotion (session's +// wireMouse decorator defers to whatever MouseMode View() sets, see the +// session package's own MouseMode test), so a regression here silently +// turns every hover treatment in this package back into dead code. +func TestViewReportsAllMotion(t *testing.T) { + m := newTestMission() + if got := m.View().MouseMode; got != tea.MouseModeAllMotion { + t.Fatalf("MouseMode = %v, want AllMotion", got) + } +} + // ─── modal width rule (owner's round-2 ruling, 2026-09-19): a foldout's // width is max(its anchor segment's width, its content's natural width) ── From 955eac6c30bf47fbe9184035e5bd5850b5f35ea8 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 21 Sep 2026 22:19:06 -0500 Subject: [PATCH 4/4] ui: force MouseModeNone when Options.Mouse is disabled Options.Mouse=false is meant to be authoritative over what the inner view requests. But when wireMouse passed the view through bare on the disabled path, a view that set an explicit mode (like mission's AllMotion for hover) would leak through, making the option a default rather than a requirement. Wrap the disabled path in a noMouseView decorator that forces MouseModeNone every frame, ensuring Options.Mouse is the final word no matter what the inner view asks for. Co-Authored-By: Claude Haiku 4.5 --- ui/internal/session/options_internal_test.go | 14 ++++++++++ ui/internal/session/session.go | 28 +++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/ui/internal/session/options_internal_test.go b/ui/internal/session/options_internal_test.go index 8cdba0d41..bb18fdf77 100644 --- a/ui/internal/session/options_internal_test.go +++ b/ui/internal/session/options_internal_test.go @@ -53,3 +53,17 @@ func TestMouseViewDefersToAnExplicitMouseMode(t *testing.T) { t.Fatalf("inner unset: MouseMode = %v, want CellMotion default", got) } } + +// TestWireMouseDisabledForcesNone ensures Options.Mouse=false is authoritative +// even when the inner view sets an explicit mode: the disabled path forces +// MouseModeNone to prevent views like mission that request AllMotion from +// leaking through. This pins the contract that Options.Mouse is the final word, +// not a default that inner modes can override. +func TestWireMouseDisabledForcesNone(t *testing.T) { + if got := wireMouse(allMotionStubView{}, Options{}).View().MouseMode; got != tea.MouseModeNone { + t.Fatalf("Mouse false with inner AllMotion: MouseMode = %v, want None", got) + } + if got := wireMouse(stubView{}, Options{}).View().MouseMode; got != tea.MouseModeNone { + t.Fatalf("Mouse false with inner unset: MouseMode = %v, want None", got) + } +} diff --git a/ui/internal/session/session.go b/ui/internal/session/session.go index 4949db0a1..013f30639 100644 --- a/ui/internal/session/session.go +++ b/ui/internal/session/session.go @@ -121,12 +121,38 @@ func (m mouseView) View() tea.View { return v } +// noMouseView decorates a View so every frame it paints reports MouseModeNone, +// regardless of what the inner view requests. This ensures Options.Mouse=false +// is authoritative... even when the inner view (like mission) sets an explicit +// mode. Without this, Options.Mouse would be a mere default that inner modes +// could override, defeating the accessibility/preference contract. +type noMouseView struct{ view View } + +func (n noMouseView) Init() tea.Cmd { return n.view.Init() } + +func (n noMouseView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + updated, cmd := n.view.Update(msg) + if v, ok := updated.(View); ok { + n.view = v + } + return n, cmd +} + +func (n noMouseView) SetModel(raw json.RawMessage) error { return n.view.SetModel(raw) } +func (n noMouseView) Reason() Reason { return n.view.Reason() } + +func (n noMouseView) View() tea.View { + v := n.view.View() + v.MouseMode = tea.MouseModeNone + return v +} + // wireMouse is where Options.Mouse actually takes effect: it costs every // view that doesn't opt in a stolen text-selection drag, so it wraps only // when asked. func wireMouse(view View, opts Options) tea.Model { if !opts.Mouse { - return view + return noMouseView{view: view} } return mouseView{view: view} }