Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ 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. 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

- `rift diff --watch` live-reloads the browser as the working tree changes —
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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. 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

- `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`).

Expand Down
2 changes: 0 additions & 2 deletions git-flux-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions internal/tui/layout.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -59,3 +61,45 @@ 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
}

// 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
}
17 changes: 10 additions & 7 deletions internal/tui/log/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +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)
}
// 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) {
Expand Down
68 changes: 55 additions & 13 deletions internal/tui/menu/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -89,6 +91,53 @@ 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:
m.moveSelection(tui.WheelDelta(msg)) // horizontal wheel maps to 0: a no-op
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 — 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) || idx >= m.contentHeight()-2 {
return m, nil
}
if idx == m.selectedIdx {
return m, m.activate(idx)
}
m.selectedIdx = idx
return m, nil
}
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 {
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
Expand Down Expand Up @@ -122,10 +171,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)
Expand Down Expand Up @@ -193,14 +239,12 @@ 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
v := tea.NewView(tui.HelpView("", "a composable fuzzy git tool", menuHints, menuNavKeys, m.width, contentH))
v.AltScreen = true
return v
contentH := m.contentHeight()
return tui.ScreenView(tui.HelpView("", "a composable fuzzy git tool", menuHints, menuNavKeys, m.width, contentH))
}

var items strings.Builder
Expand All @@ -215,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)
Expand All @@ -230,7 +274,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))
}
Loading
Loading