From c68e52da33e67f3285168454930f5b14c14888a4 Mon Sep 17 00:00:00 2001 From: madhermit <1425712+madhermit@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:53:21 -0600 Subject: [PATCH 1/5] Add mouse support across every screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wheel scrolls the pane under the pointer — over a list it moves the selection, over a preview it scrolls by three lines per notch. A left click selects the row it lands on and focuses the pane it hit; clicked rows resolve against the layout at click time, before a focus change can re-window them (clicking the one-row peek while reading returns to the list on the same item). In the menu launchpad a click on the already-selected entry launches it, matching what a click on a menu means everywhere else. Mouse mode is requested per-view (bubbletea v2's View.MouseMode) via the new tui.ScreenView helper every screen now builds its view through, so alt-screen and mouse state stay consistent across diff, log, stash, stage, the tests lens, and the menu. Cell-motion tracking only — no motion events. RIFT_MOUSE=off opts out entirely for users who prefer the terminal's native text selection; most terminals also bypass tracking with shift-click, so copying from a diff works either way. Modal states behave like the keyboard: the help overlay and inline confirm prompts ignore the mouse; the wheel still works while filtering. --- CHANGELOG.md | 7 +++ CLAUDE.md | 1 - README.md | 7 ++- git-flux-design.md | 2 - internal/tui/layout.go | 27 +++++++++ internal/tui/menu/model.go | 43 ++++++++++++-- internal/tui/splitlist.go | 88 +++++++++++++++++++++++++++- internal/tui/splitlist_mouse_test.go | 65 ++++++++++++++++++++ internal/tui/stage/model.go | 62 ++++++++++++++++++++ internal/tui/theme.go | 11 ++++ 10 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 internal/tui/splitlist_mouse_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db4e692..4140609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ pre-1.0). ### Added +- Mouse support on every screen: the wheel scrolls the pane under the pointer + (lists scroll by moving the selection), a left click selects the row it + lands on and focuses the pane it hit, and clicking the menu's selected + entry launches it. `RIFT_MOUSE=off` disables tracking for terminals where + you prefer native text selection (most terminals also bypass tracking with + shift-click/drag). + ### Fixed - `rift diff --watch` live-reloads the browser as the working tree changes — diff --git a/CLAUDE.md b/CLAUDE.md index 4da817e..f62e407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,6 @@ design intent only — do not assume the packages or tools exist: - **Checkpoints:** shadow-commit snapshots on hidden refs (`internal/checkpoint`) - **Risk classification** in review — today `internal/review` only tracks reviewed marks and extracts test cases - **Inline agent annotations:** `rift annotate` writing to a JSON store next to the reviewed marks (`.git/rift/annotations.json`), rendered in the diff preview and picked up by `--watch`. File-based on purpose — no daemon/socket (see the design doc's Future Considerations) -- **Mouse support:** wheel scroll + click-to-select via bubbletea mouse events; must keep terminal text selection workable (see design doc) - **Moved-line diff view:** a config option swapping the git engine's flags to `--color-moved=zebra --color-moved-ws=allow-indentation-change` (mutually exclusive with the default `--word-diff=color` — git silently ignores `--color-moved` under word-diff, so it can't be a combined default; see `displayFlags` in `internal/diff`) ## Workflow diff --git a/README.md b/README.md index 1869df2..1fee073 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ A new cohort of review-first diff viewers — [hunk](https://github.com/modem-de | Transient — invoke, act, return to shell | ✅ | ✅ | ❌ | ✅ | — | — | | Single static binary | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | | Inline agent annotations | 🔜 | ✅ | ❌ | ❌ | ❌ | ❌ | -| Mouse support | 🔜 | ✅ | ✅ | ❌ | — | — | +| Mouse support | ✅ | ✅ | ✅ | ❌ | — | — | ✅ yes · ❌ no · — not applicable · 🔜 planned. delta and difftastic are diff renderers/pagers rather than browsers, so several rows don't apply — rift uses difftastic as its diff engine and falls back to a built-in line diff without it. @@ -167,10 +167,15 @@ Every work screen (`diff`, `log`, `stash`, `stage`, and the tests lens) shares t The `menu` launchpad is a plain list: `⏎` selects, `/` filters, and `esc`/`q` quit. Destructive actions (`stash` pop/drop, `log` cherry-pick/revert) confirm inline with `y`/`n`. +**Mouse** + +The mouse works everywhere the keyboard does: the wheel scrolls the pane under the pointer (over a list it moves the selection), a click selects the row it lands on and focuses the pane it hit, and in the `menu` a click on the selected entry launches it. Set `RIFT_MOUSE=off` if you'd rather keep the terminal's native text selection — and note most terminals bypass mouse tracking with shift-click/drag, so copying out of a diff still works either way. + ### Environment - `RIFT_THEME=light|dark` — force the color palette instead of detecting the terminal's background. Unset, rift asks the terminal and falls back to the dark palette when there's no reply. - `RIFT_ICONS=ascii` (or `none`) — suppress the Nerd Font file-type icons for terminals without a patched font; the icon column drops out cleanly. +- `RIFT_MOUSE=off` — disable mouse tracking (wheel/click), keeping the terminal's native text selection. - `NO_COLOR` — disable all ANSI color. - `VISUAL` / `EDITOR` — the editor `o` opens (falling back to `vi`). diff --git a/git-flux-design.md b/git-flux-design.md index 60dec49..488a19e 100644 --- a/git-flux-design.md +++ b/git-flux-design.md @@ -1279,8 +1279,6 @@ The following are recorded for future exploration, not V1 scope. They become int **Moved-line diff view (config).** git's `--color-moved=zebra` classifies relocated code and renders it in distinct colors — answering "moved or rewritten?" during refactor review. It cannot ship as part of the default git view because git silently ignores `--color-moved` when `--word-diff` is active (verified empirically), and word-level emphasis is the better everyday default. Ship it as a config option (e.g. `diff.git_view = "words" | "moved"` in config.toml) rather than a UI toggle — one best-effort default, configurable when someone asks. -**Mouse support.** Click to select a row, click a pane to focus it, wheel to scroll the preview — bubbletea already delivers the events. The care point is that enabling terminal mouse tracking captures the pointer, breaking native text selection (shift-click escape hatches vary by terminal emulator), and copy-from-the-diff matters in a review tool. So: wheel scrolling and click-to-select on by default, everything else conservative, and a config/env switch to disable tracking entirely. - **Agent orchestration (`git flux agent`).** A dedicated subcommand for spawning agent worktrees with task metadata, monitoring progress across a dashboard, and managing the review→merge lifecycle. This would include `.git-flux-agent.toml` files for provenance, agent-aware status bars, and pre-flight merge checks. Worth building once the parallel-agent-worktree pattern stabilizes and we understand what orchestration agents actually need vs. what they handle themselves. **MCP server mode (`git flux mcp`).** Exposing git-flux as a Model Context Protocol server (stdio + HTTP transport) so agents can call tools like `flux_diff`, `flux_review_state`, and `flux_conflict_check` via structured protocol instead of shelling out. This is compelling but the composable `--json` CLI already serves most agent needs. MCP becomes valuable if agents need live subscriptions (e.g., "notify me when review state changes") or if the ecosystem converges on MCP as the standard agent↔tool protocol. diff --git a/internal/tui/layout.go b/internal/tui/layout.go index cd39e08..b9590d4 100644 --- a/internal/tui/layout.go +++ b/internal/tui/layout.go @@ -1,5 +1,7 @@ package tui +import tea "charm.land/bubbletea/v2" + // CollapsedListWidth is the width of the list pane when the diff pane is // active and the list collapses to a narrow strip. const CollapsedListWidth = 12 @@ -59,3 +61,28 @@ func ComputeSplitLayout(width, height int, collapsed bool, minList, maxList int) } return l } + +// ScreenView wraps rendered screen content in the standard full-screen +// tea.View: alt-screen on, and mouse tracking requested unless the user +// opted out (see MouseEnabled). Every top-level screen builds its view here +// so terminal modes stay consistent across them. +func ScreenView(content string) tea.View { + v := tea.NewView(content) + v.AltScreen = true + if MouseEnabled() { + v.MouseMode = tea.MouseModeCellMotion + } + return v +} + +// WheelDelta maps a wheel message to a vertical scroll direction: -1 up, +// 1 down, 0 for horizontal wheel events (which the screens don't use). +func WheelDelta(msg tea.MouseWheelMsg) int { + switch msg.Button { + case tea.MouseWheelUp: + return -1 + case tea.MouseWheelDown: + return 1 + } + return 0 +} diff --git a/internal/tui/menu/model.go b/internal/tui/menu/model.go index b4c684a..fb4e987 100644 --- a/internal/tui/menu/model.go +++ b/internal/tui/menu/model.go @@ -75,6 +75,8 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + return m.handleMouse(msg) case tea.KeyPressMsg: return m.handleKey(msg) case tea.WindowSizeMsg: @@ -89,6 +91,39 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// handleMouse maps mouse input onto the menu list: the wheel moves the +// selection, a click selects the row under the pointer, and a click on the +// already-selected row activates it (the menu is a launcher, so a click means +// "choose this"). +func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.showHelp || !m.ready { + return m, nil + } + switch msg := msg.(type) { + case tea.MouseWheelMsg: + if delta := tui.WheelDelta(msg); delta != 0 { + m.moveSelection(delta) + } + return m, nil + case tea.MouseClickMsg: + if msg.Button != tea.MouseLeft { + return m, nil + } + // Rows render un-windowed from the panel's first inner line. + idx := msg.Y - tui.HeaderRows - 1 + if idx < 0 || idx >= len(m.filtered) { + return m, nil + } + if idx == m.selectedIdx { + cmd := m.filtered[idx] + return m, func() tea.Msg { return SelectedMsg{Command: cmd.Name} } + } + m.selectedIdx = idx + return m, nil + } + return m, nil +} + func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit @@ -198,9 +233,7 @@ func (m Model) View() tea.View { if m.showHelp { contentH := m.height - tui.HeaderRows - tui.FooterRows - v := tea.NewView(tui.HelpView("", "a composable fuzzy git tool", menuHints, menuNavKeys, m.width, contentH)) - v.AltScreen = true - return v + return tui.ScreenView(tui.HelpView("", "a composable fuzzy git tool", menuHints, menuNavKeys, m.width, contentH)) } var items strings.Builder @@ -230,7 +263,5 @@ func (m Model) View() tea.View { footer = tui.Footer(m.width, fmt.Sprintf("%d/%d", m.selectedIdx+1, len(m.filtered)), menuHints) } - v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, header, panel, footer)) - v.AltScreen = true - return v + return tui.ScreenView(lipgloss.JoinVertical(lipgloss.Left, header, panel, footer)) } diff --git a/internal/tui/splitlist.go b/internal/tui/splitlist.go index cb83c96..6af1ce9 100644 --- a/internal/tui/splitlist.go +++ b/internal/tui/splitlist.go @@ -393,6 +393,8 @@ func (m SplitList[T]) SelectedKey() string { func (m SplitList[T]) Update(msg tea.Msg) (SplitList[T], tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + return m.handleMouse(msg) case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.ready = true @@ -572,6 +574,88 @@ func (m SplitList[T]) stepList(delta int) (SplitList[T], tea.Cmd) { return m.requestPreview() } +// WheelLines is how many preview lines one wheel notch scrolls. +const WheelLines = 3 + +// handleMouse routes a mouse event by position: the wheel scrolls whichever +// pane the pointer is over (the preview by lines, the list by selection), and +// a left click selects the row under the pointer and focuses the pane it hit. +// Modal states (help overlay, an inline confirm prompt) ignore the mouse the +// same way they capture the keyboard; wheel events still work while filtering +// since the filter only captures text input. +func (m SplitList[T]) handleMouse(msg tea.MouseMsg) (SplitList[T], tea.Cmd) { + if m.showHelp || m.prompt != "" || !m.ready { + return m, nil + } + pos := msg.Mouse() + previewH, navH := m.stackLayout() + listTop, previewTop := HeaderRows, HeaderRows+navH + inList := navH > 0 && pos.Y >= listTop && pos.Y < previewTop + inPreview := pos.Y >= previewTop && pos.Y < previewTop+previewH + + switch msg := msg.(type) { + case tea.MouseWheelMsg: + delta := WheelDelta(msg) + if delta == 0 { + return m, nil + } + if inList { + return m.stepList(delta) + } + if inPreview || navH == 0 { + if delta < 0 { + m.viewport.ScrollUp(WheelLines) + } else { + m.viewport.ScrollDown(WheelLines) + } + } + return m, nil + case tea.MouseClickMsg: + if msg.Button != tea.MouseLeft { + return m, nil + } + switch { + case inList: + // Resolve the clicked row against the layout the user clicked on, + // then focus — focusing can expand a one-row peek into the full + // strip, which would re-window the rows under the pointer. + sel, cmd := m.selectRow(pos.Y-listTop-1, navH-2) + return sel.focusPane(splitListPane), cmd + case inPreview: + return m.focusPane(splitPreviewPane), nil + } + } + return m, nil +} + +// focusPane switches the focused pane (a click's equivalent of ⇥) and +// re-lays-out, since focus decides how the vertical split divides. +func (m SplitList[T]) focusPane(p splitPane) SplitList[T] { + if m.active == p { + return m + } + m.active = p + m.resizeViewport() + return m +} + +// selectRow selects the item at the given inner list row (0-based, borders +// already subtracted), mapping through the same window the list rendered with +// (innerH is the clicked layout's inner strip height). Clicks on the border or +// past the last item keep the current selection. +func (m SplitList[T]) selectRow(row, innerH int) (SplitList[T], tea.Cmd) { + if row < 0 || row >= innerH || len(m.filtered) == 0 { + return m, nil + } + offset, _ := ListWindow(m.selected, len(m.filtered), maxInt(1, innerH)) + idx := offset + row + if idx >= len(m.filtered) || idx == m.selected { + return m, nil + } + m.selected = idx + return m.requestPreview() +} + func (m SplitList[T]) applyFilter() (SplitList[T], tea.Cmd) { // Keep the selection on the same item when it survives the new filter (matched // by the same identity key SetItemsSelecting uses); reset to the top otherwise. @@ -997,7 +1081,5 @@ func (m SplitList[T]) content() string { // TeaView wraps the rendered screen in a full-screen tea.View. Parent models // delegate their View() to this. func (m SplitList[T]) TeaView() tea.View { - v := tea.NewView(m.View()) - v.AltScreen = true - return v + return ScreenView(m.View()) } diff --git a/internal/tui/splitlist_mouse_test.go b/internal/tui/splitlist_mouse_test.go new file mode 100644 index 0000000..8f6e7ce --- /dev/null +++ b/internal/tui/splitlist_mouse_test.go @@ -0,0 +1,65 @@ +package tui + +import "testing" + +func testList(items []string) SplitList[string] { + cfg := SplitConfig[string]{ + Row: func(s string, _ int, _ bool) string { return s }, + Match: func(s string) string { return s }, + } + m := NewSplitList(cfg, items) + m.width, m.height = 80, 24 + m.ready = true + return m +} + +func TestSelectRow(t *testing.T) { + m := testList([]string{"a", "b", "c", "d", "e", "f", "g", "h"}) + _, navH := m.stackLayout() + innerH := navH - 2 + if innerH < 3 { + t.Fatalf("test needs a strip of at least 3 rows, got %d", innerH) + } + + // Un-scrolled window: row r is item r. + got, _ := m.selectRow(2, innerH) + if got.selected != 2 { + t.Errorf("selectRow(2) selected %d, want 2", got.selected) + } + + // Selection past the window scrolls the list; a click maps through the + // same offset the rows rendered with. + m.selected = len(m.items) - 1 + offset, _ := ListWindow(m.selected, len(m.items), innerH) + if offset == 0 { + t.Fatalf("expected a scrolled window for selected=%d innerH=%d", m.selected, innerH) + } + got, _ = m.selectRow(0, innerH) + if got.selected != offset { + t.Errorf("scrolled selectRow(0) selected %d, want %d", got.selected, offset) + } + + // Border and past-the-end clicks keep the selection. + for _, row := range []int{-1, innerH, len(m.items) + 5} { + got, _ = m.selectRow(row, innerH) + if got.selected != m.selected { + t.Errorf("selectRow(%d) moved selection to %d, want unchanged %d", row, got.selected, m.selected) + } + } +} + +func TestFocusPaneRelayouts(t *testing.T) { + m := testList([]string{"a", "b", "c"}) + _, surveyNav := m.stackLayout() + m = m.focusPane(splitPreviewPane) + if m.active != splitPreviewPane { + t.Fatal("focusPane did not switch panes") + } + _, readNav := m.stackLayout() + if readNav >= surveyNav { + t.Errorf("reading layout should collapse the strip: %d -> %d", surveyNav, readNav) + } + if m.focusPane(splitPreviewPane).active != splitPreviewPane { + t.Error("re-focusing the same pane should be a no-op") + } +} diff --git a/internal/tui/stage/model.go b/internal/tui/stage/model.go index b4b6a16..321c4c1 100644 --- a/internal/tui/stage/model.go +++ b/internal/tui/stage/model.go @@ -140,6 +140,8 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + return m.handleMouse(msg) case tea.KeyPressMsg: return m.handleKey(msg) case tui.EditorClosedMsg: @@ -431,6 +433,66 @@ func (m Model) reloadFiles() tea.Cmd { } } +// handleMouse routes mouse input by pane: the wheel scrolls the hunk view or +// steps the file selection, a click selects the file row under the pointer and +// focuses the pane it hit. The row is resolved against the layout the user +// clicked on, before any focus change re-widens the panes. +func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.showHelp || !m.ready { + return m, nil + } + pos := msg.Mouse() + l := m.layout() + inContent := pos.Y >= tui.HeaderRows && pos.Y < tui.HeaderRows+l.ContentHeight + inFiles := inContent && pos.X < l.ListWidth + inDiff := inContent && !inFiles + + switch msg := msg.(type) { + case tea.MouseWheelMsg: + delta := tui.WheelDelta(msg) + if delta == 0 { + return m, nil + } + if inFiles { + return m.moveSelection(delta) + } + if inDiff { + if delta < 0 { + m.viewport.ScrollUp(tui.WheelLines) + } else { + m.viewport.ScrollDown(tui.WheelLines) + } + } + return m, nil + case tea.MouseClickMsg: + if msg.Button != tea.MouseLeft { + return m, nil + } + switch { + case inFiles: + row := pos.Y - tui.HeaderRows - 1 + innerH := l.ContentHeight - 2 + focus := m + focus.activePane = filePane + if row < 0 || row >= innerH || len(m.filteredFiles) == 0 { + return focus.applyLayout() + } + offset, _ := tui.ListWindow(m.selectedIdx, len(m.filteredFiles), max(1, innerH)) + if idx := offset + row; idx < len(m.filteredFiles) && idx != m.selectedIdx { + next, cmd := focus.selectTo(idx) + focus = next.(Model) + lm, lcmd := focus.applyLayout() + return lm, tea.Batch(cmd, lcmd) + } + return focus.applyLayout() + case inDiff: + m.activePane = diffPane + return m.applyLayout() + } + } + return m, nil +} + func (m Model) applyLayout() (tea.Model, tea.Cmd) { l := m.layout() if l.DiffWidth != m.viewport.Width() { diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 9a650ba..6f63de4 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -503,3 +503,14 @@ func maxInt(a, b int) int { } return b } + +// MouseEnabled reports whether mouse tracking should be requested. On by +// default; RIFT_MOUSE=off (or 0/none) disables it for users who prefer the +// terminal's native text selection without the shift-click escape hatch. +func MouseEnabled() bool { + switch os.Getenv("RIFT_MOUSE") { + case "off", "0", "none", "false": + return false + } + return true +} From fb3598a39f6d1c1c64673968412a7a79b851ad6d Mon Sep 17 00:00:00 2001 From: madhermit <1425712+madhermit@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:35:46 -0600 Subject: [PATCH 2/5] Fix the review findings on mouse support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The big one: stage's three view sites were never converted to tui.ScreenView, so MouseMode stayed unset and — since bubbletea v2 drives terminal mouse tracking from the returned view — the stage screen never enabled tracking and its whole mouse handler was unreachable from a real terminal. The SGR-injection testing used to verify the feature couldn't catch this (bubbletea parses injected sequences whether or not tracking was requested); verification now greps the raw terminal output for the cell-motion enable sequence per screen, including the RIFT_MOUSE=off suppression. Also: - Route mouse input to the drilled view while a log drilldown is open. It previously fell through to the hidden commit list, silently moving its selection — esc landed on a different commit and a subsequent cherry-pick/revert would have targeted it. - Bound menu clicks by the panel's visible height, not just the item count, so border/footer clicks on a short terminal can't select (or launch) entries that are clipped out of view. - Make no-op stage clicks no-ops: clicking an already-focused pane no longer re-runs the layout (which snapped the diff scroll back to the selected hunk), and a selection-changing click issues one diff load instead of two (selectTo's was always discarded when applyLayout bumped the request ID again). selectTo also gains the same boundary guard stepList has, so wheeling past the list edge stops resetting the hunk selection and re-rendering. - Delegate preview/diff wheel events to the bubbles viewport, which handles them natively (including horizontal wheel) — deleting the WheelLines constant that duplicated viewport.MouseWheelDelta. - Share the windowed click-to-row mapping (tui.ClickedRow) between SplitList and stage instead of two drifting copies; route click focus through relayout like the keyboard path; dedupe the menu's enter/click activation; correct MouseEnabled's accepted-values doc. --- internal/tui/layout.go | 17 ++++++++ internal/tui/log/model.go | 6 +++ internal/tui/menu/model.go | 23 ++++++---- internal/tui/splitlist.go | 49 +++++++++------------ internal/tui/splitlist_mouse_test.go | 4 +- internal/tui/stage/model.go | 65 +++++++++++++++------------- internal/tui/theme.go | 5 ++- 7 files changed, 98 insertions(+), 71 deletions(-) diff --git a/internal/tui/layout.go b/internal/tui/layout.go index b9590d4..d61e294 100644 --- a/internal/tui/layout.go +++ b/internal/tui/layout.go @@ -86,3 +86,20 @@ func WheelDelta(msg tea.MouseWheelMsg) int { } return 0 } + +// ClickedRow maps a click on a list's inner row (0-based, panel borders +// already subtracted) to the item index it lands on, through the same window +// the list renders with (see ListWindow). ok is false when the click misses — +// a border, past the last item — or lands on the already-selected item, so +// callers treat it as "selection changed". +func ClickedRow(row, innerH, selected, total int) (int, bool) { + if row < 0 || row >= innerH || total == 0 { + return 0, false + } + offset, _ := ListWindow(selected, total, innerH) + idx := offset + row + if idx >= total || idx == selected { + return 0, false + } + return idx, true +} diff --git a/internal/tui/log/model.go b/internal/tui/log/model.go index eb28d83..d37becd 100644 --- a/internal/tui/log/model.go +++ b/internal/tui/log/model.go @@ -145,6 +145,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyPressMsg); ok && m.drilling { return m.updateDrilled(key) } + // Mouse input belongs to whichever view is visible: while drilled, routing + // it to the hidden commit list would silently move its selection (and a + // later esc/cherry-pick/revert would act on the drifted commit). + if mm, ok := msg.(tea.MouseMsg); ok && m.drilling { + return m.updateDrilled(mm) + } // The drilled tests view can open an editor (`o`); its close message must reach // that view (to re-read the edited file), not the hidden commit list below. if _, ok := msg.(tui.EditorClosedMsg); ok && m.drilling { diff --git a/internal/tui/menu/model.go b/internal/tui/menu/model.go index fb4e987..3aaeabc 100644 --- a/internal/tui/menu/model.go +++ b/internal/tui/menu/model.go @@ -109,14 +109,17 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if msg.Button != tea.MouseLeft { return m, nil } - // Rows render un-windowed from the panel's first inner line. + // Rows render un-windowed from the panel's first inner line — and the + // panel clips at its inner height, so a click must land on a row that + // is both real and visible (a border/footer click on a short terminal + // must not select, let alone launch, a clipped-away entry). idx := msg.Y - tui.HeaderRows - 1 - if idx < 0 || idx >= len(m.filtered) { + visible := m.height - tui.HeaderRows - tui.FooterRows - 2 + if idx < 0 || idx >= len(m.filtered) || idx >= visible { return m, nil } if idx == m.selectedIdx { - cmd := m.filtered[idx] - return m, func() tea.Msg { return SelectedMsg{Command: cmd.Name} } + return m, m.activate(idx) } m.selectedIdx = idx return m, nil @@ -124,6 +127,13 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } +// activate launches the command at idx — the single path shared by the enter +// key and a click on the selected row. +func (m Model) activate(idx int) tea.Cmd { + cmd := m.filtered[idx] + return func() tea.Msg { return SelectedMsg{Command: cmd.Name} } +} + func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit @@ -157,10 +167,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": if len(m.filtered) > 0 { - cmd := m.filtered[m.selectedIdx] - return m, func() tea.Msg { - return SelectedMsg{Command: cmd.Name} - } + return m, m.activate(m.selectedIdx) } case "up", "k": m.moveSelection(-1) diff --git a/internal/tui/splitlist.go b/internal/tui/splitlist.go index 6af1ce9..3a6fb76 100644 --- a/internal/tui/splitlist.go +++ b/internal/tui/splitlist.go @@ -574,9 +574,6 @@ func (m SplitList[T]) stepList(delta int) (SplitList[T], tea.Cmd) { return m.requestPreview() } -// WheelLines is how many preview lines one wheel notch scrolls. -const WheelLines = 3 - // handleMouse routes a mouse event by position: the wheel scrolls whichever // pane the pointer is over (the preview by lines, the list by selection), and // a left click selects the row under the pointer and focuses the pane it hit. @@ -595,19 +592,18 @@ func (m SplitList[T]) handleMouse(msg tea.MouseMsg) (SplitList[T], tea.Cmd) { switch msg := msg.(type) { case tea.MouseWheelMsg: - delta := WheelDelta(msg) - if delta == 0 { - return m, nil - } if inList { - return m.stepList(delta) - } - if inPreview || navH == 0 { - if delta < 0 { - m.viewport.ScrollUp(WheelLines) - } else { - m.viewport.ScrollDown(WheelLines) + if delta := WheelDelta(msg); delta != 0 { + return m.stepList(delta) } + return m, nil + } + if inPreview { + // The viewport handles wheel messages natively (vertical and + // horizontal), same as the keyboard scrolling path. + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd } return m, nil case tea.MouseClickMsg: @@ -620,23 +616,24 @@ func (m SplitList[T]) handleMouse(msg tea.MouseMsg) (SplitList[T], tea.Cmd) { // then focus — focusing can expand a one-row peek into the full // strip, which would re-window the rows under the pointer. sel, cmd := m.selectRow(pos.Y-listTop-1, navH-2) - return sel.focusPane(splitListPane), cmd + focused, fcmd := sel.focusPane(splitListPane) + return focused, tea.Batch(cmd, fcmd) case inPreview: - return m.focusPane(splitPreviewPane), nil + return m.focusPane(splitPreviewPane) } } return m, nil } -// focusPane switches the focused pane (a click's equivalent of ⇥) and -// re-lays-out, since focus decides how the vertical split divides. -func (m SplitList[T]) focusPane(p splitPane) SplitList[T] { +// focusPane switches the focused pane (a click's equivalent of ⇥) through the +// same relayout path as the keyboard, so the width-keyed-cache contract lives +// in one place. +func (m SplitList[T]) focusPane(p splitPane) (SplitList[T], tea.Cmd) { if m.active == p { - return m + return m, nil } m.active = p - m.resizeViewport() - return m + return m.relayout() } // selectRow selects the item at the given inner list row (0-based, borders @@ -644,12 +641,8 @@ func (m SplitList[T]) focusPane(p splitPane) SplitList[T] { // (innerH is the clicked layout's inner strip height). Clicks on the border or // past the last item keep the current selection. func (m SplitList[T]) selectRow(row, innerH int) (SplitList[T], tea.Cmd) { - if row < 0 || row >= innerH || len(m.filtered) == 0 { - return m, nil - } - offset, _ := ListWindow(m.selected, len(m.filtered), maxInt(1, innerH)) - idx := offset + row - if idx >= len(m.filtered) || idx == m.selected { + idx, ok := ClickedRow(row, innerH, m.selected, len(m.filtered)) + if !ok { return m, nil } m.selected = idx diff --git a/internal/tui/splitlist_mouse_test.go b/internal/tui/splitlist_mouse_test.go index 8f6e7ce..065f13f 100644 --- a/internal/tui/splitlist_mouse_test.go +++ b/internal/tui/splitlist_mouse_test.go @@ -51,7 +51,7 @@ func TestSelectRow(t *testing.T) { func TestFocusPaneRelayouts(t *testing.T) { m := testList([]string{"a", "b", "c"}) _, surveyNav := m.stackLayout() - m = m.focusPane(splitPreviewPane) + m, _ = m.focusPane(splitPreviewPane) if m.active != splitPreviewPane { t.Fatal("focusPane did not switch panes") } @@ -59,7 +59,7 @@ func TestFocusPaneRelayouts(t *testing.T) { if readNav >= surveyNav { t.Errorf("reading layout should collapse the strip: %d -> %d", surveyNav, readNav) } - if m.focusPane(splitPreviewPane).active != splitPreviewPane { + if same, _ := m.focusPane(splitPreviewPane); same.active != splitPreviewPane { t.Error("re-focusing the same pane should be a no-op") } } diff --git a/internal/tui/stage/model.go b/internal/tui/stage/model.go index 321c4c1..d0b94b9 100644 --- a/internal/tui/stage/model.go +++ b/internal/tui/stage/model.go @@ -435,7 +435,9 @@ func (m Model) reloadFiles() tea.Cmd { // handleMouse routes mouse input by pane: the wheel scrolls the hunk view or // steps the file selection, a click selects the file row under the pointer and -// focuses the pane it hit. The row is resolved against the layout the user +// focuses the pane it hit. Clicks on an already-focused pane that change +// nothing are no-ops — re-running the layout would snap the diff scroll back +// to the selected hunk. The row is resolved against the layout the user // clicked on, before any focus change re-widens the panes. func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if m.showHelp || !m.ready { @@ -449,19 +451,18 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.MouseWheelMsg: - delta := tui.WheelDelta(msg) - if delta == 0 { - return m, nil - } if inFiles { - return m.moveSelection(delta) + if delta := tui.WheelDelta(msg); delta != 0 { + return m.moveSelection(delta) + } + return m, nil } if inDiff { - if delta < 0 { - m.viewport.ScrollUp(tui.WheelLines) - } else { - m.viewport.ScrollDown(tui.WheelLines) - } + // The viewport handles wheel messages natively (vertical and + // horizontal), same as the keyboard scrolling fallthrough. + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd } return m, nil case tea.MouseClickMsg: @@ -470,22 +471,25 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } switch { case inFiles: - row := pos.Y - tui.HeaderRows - 1 - innerH := l.ContentHeight - 2 - focus := m - focus.activePane = filePane - if row < 0 || row >= innerH || len(m.filteredFiles) == 0 { - return focus.applyLayout() + target, ok := tui.ClickedRow(pos.Y-tui.HeaderRows-1, l.ContentHeight-2, m.selectedIdx, len(m.filteredFiles)) + if m.activePane != filePane { + m.activePane = filePane + if ok { + // Apply the selection before the layout's own diff request, + // so the pane flip and the new file cost one load, not two. + m.selectedIdx = target + m.hunkIdx = 0 + } + return m.applyLayout() } - offset, _ := tui.ListWindow(m.selectedIdx, len(m.filteredFiles), max(1, innerH)) - if idx := offset + row; idx < len(m.filteredFiles) && idx != m.selectedIdx { - next, cmd := focus.selectTo(idx) - focus = next.(Model) - lm, lcmd := focus.applyLayout() - return lm, tea.Batch(cmd, lcmd) + if ok { + return m.selectTo(target) } - return focus.applyLayout() + return m, nil case inDiff: + if m.activePane == diffPane { + return m, nil + } m.activePane = diffPane return m.applyLayout() } @@ -564,6 +568,9 @@ func (m Model) selectTo(idx int) (tea.Model, tea.Cmd) { if idx >= len(m.filteredFiles) { idx = len(m.filteredFiles) - 1 } + if idx == m.selectedIdx { + return m, nil // already there (or clamped back to the boundary); nothing to reload + } m.selectedIdx = idx m.hunkIdx = 0 // reset hunk selection on file change cmd := m.requestDiff() @@ -770,15 +777,13 @@ func (m *Model) renderHunks() { func (m Model) View() tea.View { if !m.ready { - return tea.NewView("Loading...") + return tui.ScreenView("Loading...") } l := m.layout() if m.showHelp { - v := tea.NewView(tui.HelpView("stage", tui.HeaderContext(m.branch, m.engines.Name()), m.hints, stageNavKeys, m.width, l.ContentHeight)) - v.AltScreen = true - return v + return tui.ScreenView(tui.HelpView("stage", tui.HeaderContext(m.branch, m.engines.Name()), m.hints, stageNavKeys, m.width, l.ContentHeight)) } collapsed := m.activePane == diffPane @@ -840,9 +845,7 @@ func (m Model) View() tea.View { footer = tui.Footer(m.width, status, m.hints) } - v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, header, content, footer)) - v.AltScreen = true - return v + return tui.ScreenView(lipgloss.JoinVertical(lipgloss.Left, header, content, footer)) } func findFileIndex(files []git.StatusFile, path string) int { diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 6f63de4..79e7d5f 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -505,8 +505,9 @@ func maxInt(a, b int) int { } // MouseEnabled reports whether mouse tracking should be requested. On by -// default; RIFT_MOUSE=off (or 0/none) disables it for users who prefer the -// terminal's native text selection without the shift-click escape hatch. +// default; RIFT_MOUSE set to off, 0, none, or false disables it for users who +// prefer the terminal's native text selection without the shift-click escape +// hatch. func MouseEnabled() bool { switch os.Getenv("RIFT_MOUSE") { case "off", "0", "none", "false": From af0261eecf4a417d9d522d26368d1af976bcbd53 Mon Sep 17 00:00:00 2001 From: madhermit <1425712+madhermit@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:50:24 -0600 Subject: [PATCH 3/5] Simplify the mouse plumbing after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cancel superseded stage diff loads: requestDiff now cancels the in-flight load's context before starting a new one, so a wheel flick across uncached files kills each superseded difftastic run instead of letting dozens complete into discarded results (the splitlist screens already had this via their stream cancel; stage did not). - One pane-switch path per screen: stage gains the focusPane helper its keyboard and mouse handlers now share (same-pane no-op included), and splitlist's tab/enter route through the focusPane the mouse work introduced instead of inlining the flip. - The log drilldown's three input-routing gates collapse into one type-switch (keys, mouse, editor-close all belong to the visible view); an allowlist by design — the parent's untagged async traffic must keep flowing to the hidden list. - Menu derives its panel geometry from one contentHeight helper for both render and click hit-test, activates through one path, and its loading frame goes through ScreenView like every other view — the last stray tea.NewView, which made ScreenView's every-screen claim false and dropped alt-screen/mouse mode on the entry frame. - Wheel handlers pass WheelDelta straight to the selection steppers (which already no-op on zero/boundary), dropping a branch per site; the unfireable navH guard in the splitlist hit-test is gone; stage's vim-jump pre-check was subsumed by selectTo's new boundary guard. --- internal/tui/log/model.go | 23 ++++++------ internal/tui/menu/model.go | 20 ++++++----- internal/tui/splitlist.go | 14 +++----- internal/tui/stage/model.go | 71 ++++++++++++++++++++++--------------- 4 files changed, 69 insertions(+), 59 deletions(-) diff --git a/internal/tui/log/model.go b/internal/tui/log/model.go index d37becd..c88e4be 100644 --- a/internal/tui/log/model.go +++ b/internal/tui/log/model.go @@ -142,19 +142,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m.updateDrilled(dm.msg) } - if key, ok := msg.(tea.KeyPressMsg); ok && m.drilling { - return m.updateDrilled(key) - } - // Mouse input belongs to whichever view is visible: while drilled, routing - // it to the hidden commit list would silently move its selection (and a - // later esc/cherry-pick/revert would act on the drifted commit). - if mm, ok := msg.(tea.MouseMsg); ok && m.drilling { - return m.updateDrilled(mm) - } - // The drilled tests view can open an editor (`o`); its close message must reach - // that view (to re-read the edited file), not the hidden commit list below. - if _, ok := msg.(tui.EditorClosedMsg); ok && m.drilling { - return m.updateDrilled(msg) + // Input (keys, mouse) and editor-close messages belong to whichever view is + // visible: while drilled they must reach the drilled view, not the hidden + // commit list — a mouse event landing there would silently move its + // selection, and a later esc/cherry-pick/revert would act on the drifted + // commit. + switch msg.(type) { + case tea.KeyPressMsg, tea.MouseMsg, tui.EditorClosedMsg: + if m.drilling { + return m.updateDrilled(msg) + } } switch msg := msg.(type) { diff --git a/internal/tui/menu/model.go b/internal/tui/menu/model.go index 3aaeabc..b9d09a1 100644 --- a/internal/tui/menu/model.go +++ b/internal/tui/menu/model.go @@ -101,9 +101,7 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { case tea.MouseWheelMsg: - if delta := tui.WheelDelta(msg); delta != 0 { - m.moveSelection(delta) - } + m.moveSelection(tui.WheelDelta(msg)) // horizontal wheel maps to 0: a no-op return m, nil case tea.MouseClickMsg: if msg.Button != tea.MouseLeft { @@ -114,8 +112,7 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // is both real and visible (a border/footer click on a short terminal // must not select, let alone launch, a clipped-away entry). idx := msg.Y - tui.HeaderRows - 1 - visible := m.height - tui.HeaderRows - tui.FooterRows - 2 - if idx < 0 || idx >= len(m.filtered) || idx >= visible { + if idx < 0 || idx >= len(m.filtered) || idx >= m.contentHeight()-2 { return m, nil } if idx == m.selectedIdx { @@ -127,6 +124,13 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } +// contentHeight is the vertical space between header and footer — the panel's +// outer height. The render and the click hit-test both derive from it, so the +// two can't desync. +func (m Model) contentHeight() int { + return m.height - tui.HeaderRows - tui.FooterRows +} + // activate launches the command at idx — the single path shared by the enter // key and a click on the selected row. func (m Model) activate(idx int) tea.Cmd { @@ -235,11 +239,11 @@ func (m *Model) moveSelection(delta int) { func (m Model) View() tea.View { if !m.ready { - return tea.NewView("Loading...") + return tui.ScreenView("Loading...") } if m.showHelp { - contentH := m.height - tui.HeaderRows - tui.FooterRows + contentH := m.contentHeight() return tui.ScreenView(tui.HelpView("", "a composable fuzzy git tool", menuHints, menuNavKeys, m.width, contentH)) } @@ -255,7 +259,7 @@ func (m Model) View() tea.View { items.WriteString(row + "\n") } - contentH := m.height - tui.HeaderRows - tui.FooterRows + contentH := m.contentHeight() panel := tui.Panel("commands", "", items.String(), m.width, contentH, true, tui.Scrollbar{}) header := tui.Header("", "a composable fuzzy git tool", m.width) diff --git a/internal/tui/splitlist.go b/internal/tui/splitlist.go index 3a6fb76..e5fa39b 100644 --- a/internal/tui/splitlist.go +++ b/internal/tui/splitlist.go @@ -485,12 +485,10 @@ func (m SplitList[T]) handleKey(msg tea.KeyPressMsg) (SplitList[T], tea.Cmd) { switch msg.String() { case "tab": - m.active = 1 - m.active - return m.relayout() + return m.focusPane(1 - m.active) case "enter": if m.active == splitListPane { - m.active = splitPreviewPane - return m.relayout() + return m.focusPane(splitPreviewPane) } case "up", "k": return m.navigate(-1) @@ -587,16 +585,14 @@ func (m SplitList[T]) handleMouse(msg tea.MouseMsg) (SplitList[T], tea.Cmd) { pos := msg.Mouse() previewH, navH := m.stackLayout() listTop, previewTop := HeaderRows, HeaderRows+navH - inList := navH > 0 && pos.Y >= listTop && pos.Y < previewTop + inList := pos.Y >= listTop && pos.Y < previewTop // empty range when there is no strip inPreview := pos.Y >= previewTop && pos.Y < previewTop+previewH switch msg := msg.(type) { case tea.MouseWheelMsg: if inList { - if delta := WheelDelta(msg); delta != 0 { - return m.stepList(delta) - } - return m, nil + // A horizontal wheel maps to delta 0, which stepList no-ops. + return m.stepList(WheelDelta(msg)) } if inPreview { // The viewport handles wheel messages natively (vertical and diff --git a/internal/tui/stage/model.go b/internal/tui/stage/model.go index d0b94b9..b58e19b 100644 --- a/internal/tui/stage/model.go +++ b/internal/tui/stage/model.go @@ -50,8 +50,9 @@ type Model struct { // hunkCache memoizes rendered hunk sets by path+width so navigation and filter // keystrokes don't re-run the whole difftastic pipeline. Invalidated per path on // stage/unstage and editor close, and wholesale on a width change. - hunkCache map[string][]displayHunk - diffReqID int // increments per diff load; stale hunkDiffsMsg results are dropped + hunkCache map[string][]displayHunk + diffReqID int // increments per diff load; stale hunkDiffsMsg results are dropped + cancelDiff context.CancelFunc // stops the superseded in-flight diff load's subprocesses viewport viewport.Model filter textinput.Model @@ -241,9 +242,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.activePane == filePane { window := m.layout().ContentHeight - 2 if next, ok := m.vim.HandleListKey(msg, m.selectedIdx, len(m.filteredFiles), window); ok { - if next == m.selectedIdx { - return m, nil // consumed with no move (e.g. a pending 'g') - } + // selectTo no-ops when next == selectedIdx (e.g. a pending 'g'). return m.selectTo(next) } } @@ -264,15 +263,12 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } case "tab": if m.activePane == filePane { - m.activePane = diffPane - } else { - m.activePane = filePane + return m.focusPane(diffPane) } - return m.applyLayout() + return m.focusPane(filePane) case "enter": if m.activePane == filePane { - m.activePane = diffPane - return m.applyLayout() + return m.focusPane(diffPane) } case "up", "k": return m.navigate(-1) @@ -452,10 +448,8 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.MouseWheelMsg: if inFiles { - if delta := tui.WheelDelta(msg); delta != 0 { - return m.moveSelection(delta) - } - return m, nil + // A horizontal wheel maps to delta 0, which selectTo no-ops. + return m.moveSelection(tui.WheelDelta(msg)) } if inDiff { // The viewport handles wheel messages natively (vertical and @@ -473,30 +467,36 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { case inFiles: target, ok := tui.ClickedRow(pos.Y-tui.HeaderRows-1, l.ContentHeight-2, m.selectedIdx, len(m.filteredFiles)) if m.activePane != filePane { - m.activePane = filePane if ok { // Apply the selection before the layout's own diff request, // so the pane flip and the new file cost one load, not two. m.selectedIdx = target m.hunkIdx = 0 } - return m.applyLayout() + return m.focusPane(filePane) } if ok { return m.selectTo(target) } return m, nil case inDiff: - if m.activePane == diffPane { - return m, nil - } - m.activePane = diffPane - return m.applyLayout() + return m.focusPane(diffPane) } } return m, nil } +// focusPane switches the focused pane and re-lays-out (the split widths depend +// on focus); a click or key targeting the already-active pane is a no-op, so +// it can't snap the diff scroll back to the selected hunk. +func (m Model) focusPane(p pane) (tea.Model, tea.Cmd) { + if m.activePane == p { + return m, nil + } + m.activePane = p + return m.applyLayout() +} + func (m Model) applyLayout() (tea.Model, tea.Cmd) { l := m.layout() if l.DiffWidth != m.viewport.Width() { @@ -600,6 +600,13 @@ func (m *Model) invalidatePath(path string) { func (m *Model) requestDiff() tea.Cmd { m.diffReqID++ m.flash = "" // a new selection / reload clears any transient message + if m.cancelDiff != nil { + // A newer selection or layout supersedes the in-flight load: kill its + // subprocesses instead of letting them run to a discarded result (a + // wheel flick would otherwise pile up concurrent difftastic runs). + m.cancelDiff() + m.cancelDiff = nil + } if len(m.filteredFiles) == 0 { m.setHunks(nil) return nil @@ -609,7 +616,9 @@ func (m *Model) requestDiff() tea.Cmd { m.setHunks(hunks) return nil } - return m.loadSelectedDiff(m.diffReqID) + ctx, cancel := context.WithCancel(context.Background()) + m.cancelDiff = cancel + return m.loadSelectedDiff(ctx, m.diffReqID) } // setHunks installs a rendered hunk set and positions the viewport on the @@ -628,7 +637,7 @@ func (m *Model) setHunks(hunks []displayHunk) { } } -func (m Model) loadSelectedDiff(reqID int) tea.Cmd { +func (m Model) loadSelectedDiff(ctx context.Context, reqID int) tea.Cmd { if len(m.filteredFiles) == 0 { return nil } @@ -645,25 +654,29 @@ func (m Model) loadSelectedDiff(reqID int) tea.Cmd { raw, err := diff.RawNewFileDiff(repoRoot, f.Path) if err == nil && raw != "" { result = append(result, buildDisplayHunks( - engine, raw, f.Path, repoRoot, false, color, width-2, + ctx, engine, raw, f.Path, repoRoot, false, color, width-2, )...) } } else { raw, err := diff.RawUnifiedDiff(repoRoot, false, f.Path) if err == nil && raw != "" { result = append(result, buildDisplayHunks( - engine, raw, f.Path, repoRoot, false, color, width-2, + ctx, engine, raw, f.Path, repoRoot, false, color, width-2, )...) } raw, err = diff.RawUnifiedDiff(repoRoot, true, f.Path) if err == nil && raw != "" { result = append(result, buildDisplayHunks( - engine, raw, f.Path, repoRoot, true, color, width-2, + ctx, engine, raw, f.Path, repoRoot, true, color, width-2, )...) } } + if ctx.Err() != nil { + return nil // superseded mid-load; the result would be dropped anyway + } + // Order by new-side position so staged and unstaged hunks interleave in file // order rather than grouping by state — staging one then doesn't jump its row. sort.SliceStable(result, func(i, j int) bool { @@ -674,7 +687,7 @@ func (m Model) loadSelectedDiff(reqID int) tea.Cmd { } } -func buildDisplayHunks(engine diff.Engine, raw, path, repoRoot string, staged, color bool, width int) []displayHunk { +func buildDisplayHunks(ctx context.Context, engine diff.Engine, raw, path, repoRoot string, staged, color bool, width int) []displayHunk { fileDiffs := diff.ParseUnifiedDiff(raw) var allHunks []diff.Hunk for _, fd := range fileDiffs { @@ -684,7 +697,7 @@ func buildDisplayHunks(engine diff.Engine, raw, path, repoRoot string, staged, c return nil } base, _ := diff.BaseContent(repoRoot, staged, path) - rendered := engine.DiffHunks(context.Background(), allHunks, path, base, color, width) + rendered := engine.DiffHunks(ctx, allHunks, path, base, color, width) var result []displayHunk flatIdx := 0 From 30c52f573f730b6fb122f02274f2c63b661d9319 Mon Sep 17 00:00:00 2001 From: madhermit <1425712+madhermit@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:17:25 -0600 Subject: [PATCH 4/5] Add drag-to-copy selection in diff previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging across a preview selects display lines — highlighted in reverse video as you drag (re-asserted across the content's own SGR resets, so styled diff lines highlight end to end) — and releasing copies them through the same clipboard path as y, so the OSC 52 fallback covers SSH sessions. The copy is the displayed text, ANSI-stripped and right-trimmed: no panel borders, no scrollbar column, none of the artifacts a terminal-native selection would grab. Selection is anchored to display-line indices at press time, and the focus change a click normally performs waits for a clean release — focusing can collapse the list strip and shift the content under the pointer mid-drag. A plain click (press + release, no motion) focuses the preview exactly as before; cell-motion tracking already reports drag motion, so no new tracking mode is requested. --- CHANGELOG.md | 8 +- README.md | 2 +- internal/tui/splitlist.go | 115 +++++++++++++++++++++++++-- internal/tui/splitlist_mouse_test.go | 21 ++++- internal/tui/vimnav.go | 4 + 5 files changed, 137 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4140609..d4369b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,11 @@ pre-1.0). - Mouse support on every screen: the wheel scrolls the pane under the pointer (lists scroll by moving the selection), a left click selects the row it lands on and focuses the pane it hit, and clicking the menu's selected - entry launches it. `RIFT_MOUSE=off` disables tracking for terminals where - you prefer native text selection (most terminals also bypass tracking with - shift-click/drag). + entry launches it. Dragging across a diff preview selects display lines — + highlighted as you drag — and releasing copies them to the clipboard, + ANSI-stripped and without borders or gutters (OSC 52 fallback over SSH). + `RIFT_MOUSE=off` disables tracking for terminals where you prefer native + text selection (most terminals also bypass tracking with shift-click/drag). ### Fixed diff --git a/README.md b/README.md index 1fee073..54670b8 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ The `menu` launchpad is a plain list: `⏎` selects, `/` filters, and `esc`/`q` **Mouse** -The mouse works everywhere the keyboard does: the wheel scrolls the pane under the pointer (over a list it moves the selection), a click selects the row it lands on and focuses the pane it hit, and in the `menu` a click on the selected entry launches it. Set `RIFT_MOUSE=off` if you'd rather keep the terminal's native text selection — and note most terminals bypass mouse tracking with shift-click/drag, so copying out of a diff still works either way. +The mouse works everywhere the keyboard does: the wheel scrolls the pane under the pointer (over a list it moves the selection), a click selects the row it lands on and focuses the pane it hit, and in the `menu` a click on the selected entry launches it. Drag across a diff preview to select lines — they highlight as you drag, and releasing copies them to the clipboard (ANSI-stripped, no borders or gutters; OSC 52 fallback over SSH). Set `RIFT_MOUSE=off` if you'd rather keep the terminal's native text selection — and note most terminals bypass mouse tracking with shift-click/drag either way. ### Environment diff --git a/internal/tui/splitlist.go b/internal/tui/splitlist.go index e5fa39b..2ad5dd9 100644 --- a/internal/tui/splitlist.go +++ b/internal/tui/splitlist.go @@ -115,6 +115,13 @@ type SplitList[T any] struct { cache map[string]string // rendered previews, keyed by cfg.CacheKey reqID int // increments per preview request; stale results are dropped + // Mouse drag selection over the preview, in display-line indices. anchor is + // -1 when no selection is in progress; dragging turns true on the first + // motion event, so a plain click never highlights or copies. + selAnchor int + selEnd int + dragging bool + width, height int ready bool } @@ -122,13 +129,14 @@ type SplitList[T any] struct { // NewSplitList builds a SplitList for the given items. func NewSplitList[T any](cfg SplitConfig[T], items []T) SplitList[T] { return SplitList[T]{ - cfg: cfg, - items: items, - filtered: items, - viewport: viewport.New(), - filter: NewFilterInput(), - spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), - cache: map[string]string{}, + cfg: cfg, + items: items, + filtered: items, + viewport: viewport.New(), + filter: NewFilterInput(), + spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), + cache: map[string]string{}, + selAnchor: -1, } } @@ -155,6 +163,7 @@ func (m SplitList[T]) requestPreview() (SplitList[T], tea.Cmd) { m.prevErr = nil // a new selection clears any previous load error m.flash = "" // and any transient message (e.g. "copied …") m.anchored = false // a new selection re-anchors its preview + m.clearDrag() // and any in-progress mouse selection reqID := m.reqID // Every selection change signals the parent so it cancels the previous item's // stream; CacheHit tells it the content is already shown, so start no new load. @@ -608,6 +617,7 @@ func (m SplitList[T]) handleMouse(msg tea.MouseMsg) (SplitList[T], tea.Cmd) { } switch { case inList: + m.clearDrag() // Resolve the clicked row against the layout the user clicked on, // then focus — focusing can expand a one-row peek into the full // strip, which would re-window the rows under the pointer. @@ -615,12 +625,79 @@ func (m SplitList[T]) handleMouse(msg tea.MouseMsg) (SplitList[T], tea.Cmd) { focused, fcmd := sel.focusPane(splitListPane) return focused, tea.Batch(cmd, fcmd) case inPreview: + // Anchor a possible drag selection at the pressed display line. Focus + // waits for the release: focusing can collapse the strip and shift + // the content under the pointer mid-drag. + m.selAnchor = m.previewLineAt(pos.Y, previewTop) + m.selEnd = m.selAnchor + return m, nil + } + case tea.MouseMotionMsg: + // Cell-motion tracking only reports motion while a button is held, so + // this is a drag: extend the selection to the line under the pointer. + if m.selAnchor >= 0 && msg.Button == tea.MouseLeft && inPreview { + m.dragging = true + m.selEnd = m.previewLineAt(pos.Y, previewTop) + } + return m, nil + case tea.MouseReleaseMsg: + if m.selAnchor < 0 { + return m, nil + } + if !m.dragging { + // A clean click (press + release, no motion): focus the preview. + m.clearDrag() return m.focusPane(splitPreviewPane) } + a, b := m.selAnchor, m.selEnd + m.clearDrag() + return m.copySelection(a, b) } return m, nil } +// clearDrag abandons any in-progress mouse selection. +func (m *SplitList[T]) clearDrag() { + m.selAnchor, m.selEnd, m.dragging = -1, -1, false +} + +// previewLineAt maps a screen row inside the preview pane to its display-line +// index (accounting for the panel's top border and the scroll offset), clamped +// to the content; -1 when there is nothing to select. +func (m SplitList[T]) previewLineAt(y, previewTop int) int { + lines := m.vim.Lines() + if len(lines) == 0 { + return -1 + } + return clampIndex(m.viewport.YOffset()+y-previewTop-1, len(lines)) +} + +// copySelection copies the drag-selected display lines to the clipboard, +// ANSI-stripped and right-trimmed — the mouse counterpart of `y`, for lifting +// code out of a diff without the borders and gutters a terminal-native +// selection would grab. +func (m SplitList[T]) copySelection(a, b int) (SplitList[T], tea.Cmd) { + lines := m.vim.Lines() + if len(lines) == 0 || a < 0 || b < 0 { + return m, nil + } + if a > b { + a, b = b, a + } + a, b = clampIndex(a, len(lines)), clampIndex(b, len(lines)) + stripped := make([]string, 0, b-a+1) + for _, l := range lines[a : b+1] { + stripped = append(stripped, strings.TrimRight(ansi.Strip(l), " ")) + } + var cmd tea.Cmd + _, cmd = YankToClipboard(strings.Join(stripped, "\n")) + m.flash = fmt.Sprintf("copied %d lines", b-a+1) + if b == a { + m.flash = "copied 1 line" + } + return m, cmd +} + // focusPane switches the focused pane (a click's equivalent of ⇥) through the // same relayout path as the keyboard, so the width-keyed-cache contract lives // in one place. @@ -943,7 +1020,29 @@ func (m SplitList[T]) previewHeader() string { // previewBodyView is the viewport content plus its scrollbar. The current file // is shown in the legend, not the body — see currentSection / content. func (m SplitList[T]) previewBodyView() (string, Scrollbar) { - return m.viewport.View(), ScrollbarFor(&m.viewport) + body := m.viewport.View() + if m.selAnchor >= 0 && m.dragging { + body = highlightRows(body, m.viewport.YOffset(), m.selAnchor, m.selEnd) + } + return body, ScrollbarFor(&m.viewport) +} + +// highlightRows renders the drag selection: visible rows whose display-line +// index falls in [a, b] get reverse video, re-asserted after any SGR reset +// inside the line so styled diff content stays highlighted end to end. +func highlightRows(body string, yOffset, a, b int) string { + if a > b { + a, b = b, a + } + lines := strings.Split(body, "\n") + for i := range lines { + if line := yOffset + i; line >= a && line <= b { + l := strings.ReplaceAll(lines[i], "\x1b[0m", "\x1b[0m\x1b[7m") + l = strings.ReplaceAll(l, "\x1b[m", "\x1b[m\x1b[7m") + lines[i] = "\x1b[7m" + l + "\x1b[27m" + } + } + return strings.Join(lines, "\n") } // footerView renders the bottom bar: a parent-set prompt (e.g. an inline y/n diff --git a/internal/tui/splitlist_mouse_test.go b/internal/tui/splitlist_mouse_test.go index 065f13f..f3c2646 100644 --- a/internal/tui/splitlist_mouse_test.go +++ b/internal/tui/splitlist_mouse_test.go @@ -1,6 +1,9 @@ package tui -import "testing" +import ( + "strings" + "testing" +) func testList(items []string) SplitList[string] { cfg := SplitConfig[string]{ @@ -63,3 +66,19 @@ func TestFocusPaneRelayouts(t *testing.T) { t.Error("re-focusing the same pane should be a no-op") } } + +func TestHighlightRows(t *testing.T) { + body := "one\n\x1b[31mtwo\x1b[0m tail\nthree" + got := highlightRows(body, 10, 11, 11) // only the middle visible row (line 11) + lines := strings.Split(got, "\n") + if strings.Contains(lines[0], "\x1b[7m") || strings.Contains(lines[2], "\x1b[7m") { + t.Errorf("unselected rows must not be highlighted: %q", got) + } + if !strings.HasPrefix(lines[1], "\x1b[7m") { + t.Errorf("selected row missing reverse video: %q", lines[1]) + } + // The reset inside the styled segment must re-assert reverse video. + if !strings.Contains(lines[1], "\x1b[0m\x1b[7m") { + t.Errorf("reset inside selected row not re-asserted: %q", lines[1]) + } +} diff --git a/internal/tui/vimnav.go b/internal/tui/vimnav.go index c1f8d91..45bb0c8 100644 --- a/internal/tui/vimnav.go +++ b/internal/tui/vimnav.go @@ -17,6 +17,10 @@ type VimNav struct { lines []string // finalized display lines (banners stripped), the render source } +// Lines returns the finalized display lines — exactly what the viewport +// renders, banner-stripped. The mouse selection reads them to copy content. +func (v *VimNav) Lines() []string { return v.lines } + // section is a file boundary in the preview: the displayed line where the file // begins, and its label (path) taken from the non-displayed "── path ──" banner. type section struct { From a094db01e60abbbdf3c56c4fa60cdd015291c10b Mon Sep 17 00:00:00 2001 From: madhermit <1425712+madhermit@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:19:09 -0600 Subject: [PATCH 5/5] Don't collapse the list strip on mouse focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the preview used to enter the reading layout — the strip snapping down to a one-row peek under the pointer, which reads as the file list 'scoping down' to the selected file. Focus and the peek collapse are now separate: a click focuses the pane it hit (border highlight, key routing) while the layout stays put, and the collapse remains a deliberate keyboard gesture (⇥/⏎ set peek; focusing the list by any route leaves it, so clicking the peek row still expands). --- internal/tui/splitlist.go | 28 ++++++++++++++++++++-------- internal/tui/splitlist_mouse_test.go | 24 +++++++++++++++++++----- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/internal/tui/splitlist.go b/internal/tui/splitlist.go index 2ad5dd9..e8e6a4f 100644 --- a/internal/tui/splitlist.go +++ b/internal/tui/splitlist.go @@ -107,6 +107,7 @@ type SplitList[T any] struct { anchored bool // the preview has been scrolled to the selection's PreviewLine spinner spinner.Model + peek bool // reading layout: the strip collapsed to a one-row peek (⇥/⏎) loading bool // a preview is being loaded for the current selection showHelp bool // the keybinding overlay is open flash string // transient footer message (e.g. "copied …"), cleared on nav @@ -494,9 +495,12 @@ func (m SplitList[T]) handleKey(msg tea.KeyPressMsg) (SplitList[T], tea.Cmd) { switch msg.String() { case "tab": - return m.focusPane(1 - m.active) + target := 1 - m.active + m.peek = target == splitPreviewPane + return m.focusPane(target) case "enter": if m.active == splitListPane { + m.peek = true return m.focusPane(splitPreviewPane) } case "up", "k": @@ -698,11 +702,17 @@ func (m SplitList[T]) copySelection(a, b int) (SplitList[T], tea.Cmd) { return m, cmd } -// focusPane switches the focused pane (a click's equivalent of ⇥) through the -// same relayout path as the keyboard, so the width-keyed-cache contract lives -// in one place. +// focusPane switches the focused pane through the same relayout path as the +// keyboard, so the width-keyed-cache contract lives in one place. Focusing the +// list always leaves the peek layout (clicking the peek row expands the strip); +// focusing the preview never enters it — the collapse stays a keyboard gesture +// (callers set peek for ⇥/⏎). func (m SplitList[T]) focusPane(p splitPane) (SplitList[T], tea.Cmd) { - if m.active == p { + wasPeek := m.peek + if p == splitListPane { + m.peek = false + } + if m.active == p && m.peek == wasPeek { return m, nil } m.active = p @@ -760,9 +770,11 @@ func (m SplitList[T]) stackLayout() (previewH, navH int) { if contentH < 2*panelMin { return contentH, 0 } - // Reading: the strip shrinks to a one-row peek of the current item, the - // focused preview takes the rest. - if m.active == splitPreviewPane { + // Reading (entered with ⇥/⏎): the strip shrinks to a one-row peek of the + // current item, the focused preview takes the rest. A mouse click focuses + // without collapsing — re-layouting the screen under the pointer is + // jarring — so this keys off peek, not focus. + if m.peek { return contentH - panelMin, panelMin } capPct := m.cfg.NavFraction diff --git a/internal/tui/splitlist_mouse_test.go b/internal/tui/splitlist_mouse_test.go index f3c2646..8627b08 100644 --- a/internal/tui/splitlist_mouse_test.go +++ b/internal/tui/splitlist_mouse_test.go @@ -54,16 +54,30 @@ func TestSelectRow(t *testing.T) { func TestFocusPaneRelayouts(t *testing.T) { m := testList([]string{"a", "b", "c"}) _, surveyNav := m.stackLayout() + + // A mouse click focuses the preview without entering the peek layout — + // re-layouting the screen under the pointer is jarring. m, _ = m.focusPane(splitPreviewPane) if m.active != splitPreviewPane { t.Fatal("focusPane did not switch panes") } - _, readNav := m.stackLayout() - if readNav >= surveyNav { - t.Errorf("reading layout should collapse the strip: %d -> %d", surveyNav, readNav) + if _, nav := m.stackLayout(); nav != surveyNav { + t.Errorf("click focus must not collapse the strip: %d -> %d", surveyNav, nav) + } + + // The keyboard gesture (⇥/⏎) sets peek before focusing: that collapses. + m.peek = true + if _, nav := m.stackLayout(); nav >= surveyNav { + t.Errorf("peek layout should collapse the strip: %d -> %d", surveyNav, nav) + } + + // Focusing the list (click on the peek row) leaves the peek layout. + m, _ = m.focusPane(splitListPane) + if m.peek { + t.Error("focusing the list should clear the peek layout") } - if same, _ := m.focusPane(splitPreviewPane); same.active != splitPreviewPane { - t.Error("re-focusing the same pane should be a no-op") + if _, nav := m.stackLayout(); nav != surveyNav { + t.Errorf("strip should expand when the list takes focus: got %d, want %d", surveyNav, nav) } }