Skip to content
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "revdiff",
"source": "./",
"description": "Review git diffs with inline annotations in a TUI overlay",
"version": "0.2.0",
"version": "0.2.1",
"author": {
"name": "umputun"
}
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "revdiff",
"version": "0.2.0",
"version": "0.2.1",
"description": "Review git diffs with inline annotations in a TUI overlay",
"author": {
"name": "umputun",
Expand Down
1 change: 1 addition & 0 deletions .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ revdiff HEAD~1 # review last commit
| `v` | Toggle collapsed diff mode (shows final text with change markers) |
| `.` | Expand/collapse individual hunk under cursor (collapsed mode only) |
| `f` | Toggle filter: all files / annotated only |
| `?` | Toggle help overlay showing all keybindings |
| `q` | Quit, output annotations to stdout |
| `Q` | Discard all annotations and quit (confirms if annotations exist) |

Expand Down
19 changes: 17 additions & 2 deletions .claude-plugin/skills/revdiff/scripts/launch-revdiff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,24 @@ trap 'rm -f "$OUTPUT_FILE"' EXIT
REVDIFF_CMD="$REVDIFF_BIN --output=$OUTPUT_FILE $*"
CWD="$(pwd)"

# build descriptive title: "rd: dirname [ref]"
DIR_NAME=$(basename "$CWD")
TITLE_REF=""
SKIP_NEXT=0
for arg in "$@"; do
if [ "$SKIP_NEXT" -eq 1 ]; then SKIP_NEXT=0; continue; fi
case "$arg" in
-o|--output) SKIP_NEXT=1 ;;
--output=*) ;;
-*) ;;
*) TITLE_REF="$arg"; break ;;
esac
done
OVERLAY_TITLE="rd: ${DIR_NAME}${TITLE_REF:+ [$TITLE_REF]}"

# tmux: display-popup -E blocks until command exits
if [ -n "${TMUX:-}" ] && command -v tmux >/dev/null 2>&1; then
tmux display-popup -E -w 90% -h 90% -T " revdiff " -d "$CWD" -- sh -c "$REVDIFF_CMD"
tmux display-popup -E -w 90% -h 90% -T " $OVERLAY_TITLE " -d "$CWD" -- sh -c "$REVDIFF_CMD"
cat "$OUTPUT_FILE"
exit 0
fi
Expand All @@ -33,7 +48,7 @@ if [ -n "$KITTY_SOCK" ] && command -v kitty >/dev/null 2>&1; then
SENTINEL=$(mktemp /tmp/revdiff-done-XXXXXX)
rm -f "$SENTINEL"

KITTY_ARGS=(kitty @ --to "$KITTY_SOCK" launch --type=overlay --title="revdiff" --cwd="$CWD")
KITTY_ARGS=(kitty @ --to "$KITTY_SOCK" launch --type=overlay --title="$OVERLAY_TITLE" --cwd="$CWD")
if [ -n "${KITTY_WINDOW_ID:-}" ]; then
KITTY_ARGS+=(--match "id:${KITTY_WINDOW_ID}")
fi
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ git diff → diff.ParseUnifiedDiff() → []DiffLine
- `DiffLine.Content` has no `+`/`-` prefix - prefix is re-added at render time
- Tab replacement happens at render time in `renderDiffLine`, not in diff parsing
- `run()` resolves git repo root via `git rev-parse --show-toplevel` so revdiff works from any subdirectory
- Help overlay uses `overlayCenter()` (ANSI-aware compositing via `charmbracelet/x/ansi.Cut`) to render on top of existing content; background (tree pane) remains visible at the edges
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Built for a specific use case: reviewing code changes without leaving a terminal
- Two-pane TUI: file tree (left) + colorized diff viewport (right)
- Hunk navigation to jump between change groups
- Filter file tree to show only annotated files
- Status line with filename, diff stats, hunk position, and mode indicators
- Help overlay (`?`) showing all keybindings organized by section
- Fully customizable colors via environment variables, CLI flags, or config file

![revdiff screenshot](screenshot.png)
Expand Down Expand Up @@ -233,6 +235,7 @@ revdiff HEAD~1
| `v` | Toggle collapsed diff mode (shows final text with change markers) |
| `.` | Expand/collapse individual hunk under cursor (collapsed mode only) |
| `f` | Toggle filter: all files / annotated only (shown when annotations exist) |
| `?` | Toggle help overlay showing all keybindings |
| `q` | Quit, output annotations to stdout |
| `Q` | Discard all annotations and quit (confirms if annotations exist) |

Expand Down
139 changes: 139 additions & 0 deletions docs/plans/completed/20260402-status-line-help-overlay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Split Status Bar into Status Line + Help Overlay

## Overview
Replace the current single status bar (which mixes status info and shortcut hints) with two separate concerns:
1. **Status line** — shows file info, diff stats, hunk position, mode indicators, annotation count
2. **Help overlay** — a modal popup triggered by `?` showing all keybindings organized by section

The current `statusBarText()` in `ui/model.go:488-551` is crowded and hard to scan. Splitting status from help makes both more useful: the status line becomes a clean info strip, and the help overlay provides comprehensive reference without cluttering the screen.

## Context
- **Primary file:** `ui/model.go` — `statusBarText()` (lines 488-551), `View()` (lines 442-486), `handleKey()` (lines 188-259)
- **Styles:** `ui/styles.go` — `StatusBar` style, `Colors` struct
- **Diff navigation:** `ui/diffview.go` — `currentHunk()`, `findHunks()`
- **Collapsed mode:** `ui/collapsed.go` — `collapsedState`
- **Model fields:** `ui/model.go` — `currFile`, `diffLines`, `store`, `collapsed`, etc.

## Solution Overview

### Status line layout (left to right)
```
filename +N/-N hunk X/Y ▼ ◉ 3 annotations ? help
```
- **filename** — current file path (truncated from left with `…` if too long)
- **+N/-N** — additions/deletions count for the current file
- **hunk X/Y** — current hunk position (only when cursor is on a changed line)
- **▼** — collapsed mode indicator (only when active)
- **◉** — filter active indicator (only when active)
- **right-aligned:** annotation count + `? help` hint

### Help overlay
- Triggered by `?` key, dismissed by `?` or `esc`
- Centered bordered box rendered on top of the main view
- Sections: Navigation, Annotations, View, Quit
- Uses lipgloss border styling consistent with existing pane borders

## Technical Details

### New fields in Model
- `showHelp bool` — true when help overlay is visible
- No new files needed — help rendering goes in a new `helpOverlay()` method in `model.go`

### File stats computation
- Count adds/removes from `m.diffLines` on file load (in `handleFileLoaded`)
- Cache as `fileAdds int`, `fileRemoves int` fields on Model
- Reset on file change

### Status line segments
Each segment is a small string. Segments are joined with double-space separators. Right-aligned section uses padding like current implementation.

### Help overlay rendering
- Build help text as a lipgloss-bordered box
- When `m.showHelp` is true, `View()` replaces the main content area with the centered help popup (standard bubbletea modal pattern — no true compositing, the help box replaces tree+diff content)
- Use `lipgloss.Place(m.width, paneHeight, lipgloss.Center, lipgloss.Center, helpBox)` + status bar below
- Note: bubbletea reports `?` key correctly via `msg.String()` (shifted `/` key)

### Narrow terminal handling
- Truncate filename from left with `…` prefix when space is tight
- Drop lower-priority segments (hunk, mode icons) if width is insufficient

## Development Approach
- **Testing approach:** regular (code first, then tests)
- Complete each task fully before moving to the next
- Run tests after each change
- Maintain backward compatibility (existing CLI flags, config, styles all still work)

## Implementation Steps

### Task 1: Compute and cache file diff stats

**Files:**
- Modify: `ui/model.go`
- Modify: `ui/model_test.go`

- [x] add `fileAdds` and `fileRemoves` int fields to `Model` struct
- [x] add `computeFileStats()` method that counts add/remove lines from `m.diffLines`
- [x] call `computeFileStats()` in `handleFileLoaded` after setting `m.diffLines`
- [x] write tests for `computeFileStats()` with various diff line combinations
- [x] run `make test` — must pass before task 2

### Task 2: Rewrite status line and update tests

**Files:**
- Modify: `ui/model.go`
- Modify: `ui/model_test.go`

- [x] rewrite `statusBarText()` to show: filename, +N/-N stats, hunk X/Y, mode icons (▼ ◉), right-aligned annotation count + `? help`
- [x] keep special cases for `inConfirmDiscard` and `annotating` modes unchanged
- [x] implement filename truncation with `…` prefix for narrow terminals
- [x] drop hunk and mode icons gracefully when terminal is too narrow
- [x] update existing `statusBarText` tests to match new format (no shortcut hints, has filename/stats)
- [x] add test cases for: filename truncation, mode indicators present/absent, stats display
- [x] add test cases for narrow terminal width graceful degradation
- [x] run `make test` — must pass before task 3

### Task 3: Add help overlay rendering

**Files:**
- Modify: `ui/model.go`

- [x] add `showHelp bool` field to Model
- [x] add `helpOverlay()` method returning the bordered help text with sections (Navigation, Annotations, View, Quit)
- [x] modify `View()` to overlay help popup using `lipgloss.Place()` when `m.showHelp` is true
- [x] write tests for `helpOverlay()` verifying section headers and key listings are present
- [x] run `make test` — must pass before task 4

### Task 4: Wire up `?` key handling

**Files:**
- Modify: `ui/model.go`
- Modify: `ui/model_test.go`

- [x] handle `?` key in `handleKey()` to toggle `m.showHelp`
- [x] handle `esc` key to close help when `m.showHelp` is true
- [x] block all other key handling when help overlay is showing (except `?` and `esc`)
- [x] write tests for help toggle behavior (open, close with ?, close with esc)
- [x] write test that other keys are blocked when help is showing
- [x] run `make test` — must pass before task 5

### Task 5: Verify acceptance criteria
- [x] verify status line shows filename, stats, hunk, mode icons, annotations, help hint
- [x] verify help overlay opens with `?` and closes with `?` or `esc`
- [x] verify no shortcut hints in status bar anymore (all moved to help overlay)
- [x] verify special modes (annotation input, discard confirm) still work in status bar
- [x] run full test suite: `make test`
- [x] run linter: `make lint`

### Task 6: [Final] Update documentation
- [x] update README.md with new `?` help shortcut and status line description
- [x] update `.claude-plugin/skills/revdiff/references/usage.md` with `?` help keybinding
- [x] update CLAUDE.md if any new patterns discovered
- [x] move this plan to `docs/plans/completed/`

## Post-Completion

**Manual verification:**
- test with narrow terminal widths (< 80 cols) to verify truncation
- test with large diffs (many hunks) to verify hunk counter
- test collapsed mode + filter active to verify both icons show
- verify help overlay looks correct with different color themes
63 changes: 15 additions & 48 deletions ui/collapsed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1076,7 +1076,7 @@ func TestModel_CollapsedPageUpSkipsRemovedLines(t *testing.T) {
assert.NotEqual(t, diff.ChangeRemove, dl.ChangeType, "cursor should not land on hidden removed line")
}

func TestModel_StatusBarViewModeHint(t *testing.T) {
func TestModel_StatusBarCollapsedIndicator(t *testing.T) {
lines := []diff.DiffLine{
{NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext},
{NewNum: 2, Content: "add", ChangeType: diff.ChangeAdd},
Expand All @@ -1087,29 +1087,21 @@ func TestModel_StatusBarViewModeHint(t *testing.T) {
m.focus = paneDiff
m.width = 200

t.Run("expanded mode shows collapse hint", func(t *testing.T) {
t.Run("expanded mode has no collapsed indicator", func(t *testing.T) {
m.collapsed.enabled = false
status := m.statusBarText(m.annotatedFiles())
assert.Contains(t, status, "[v] collapse")
assert.NotContains(t, status, "[v] expand")
status := m.statusBarText()
assert.NotContains(t, status, "▼")
})

t.Run("collapsed mode shows expand hint", func(t *testing.T) {
t.Run("collapsed mode shows indicator", func(t *testing.T) {
m.collapsed.enabled = true
m.collapsed.expandedHunks = make(map[int]bool)
status := m.statusBarText(m.annotatedFiles())
assert.Contains(t, status, "[v] expand")
assert.NotContains(t, status, "[v] collapse")
})

t.Run("tree pane does not show view mode hint", func(t *testing.T) {
m.focus = paneTree
status := m.statusBarText(m.annotatedFiles())
assert.NotContains(t, status, "[v]")
status := m.statusBarText()
assert.Contains(t, status, "▼")
})
}

func TestModel_StatusBarDotHint(t *testing.T) {
func TestModel_StatusBarNoShortcutHints(t *testing.T) {
lines := []diff.DiffLine{
{NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext},
{OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove},
Expand All @@ -1121,39 +1113,14 @@ func TestModel_StatusBarDotHint(t *testing.T) {
m.currFile = "a.go"
m.focus = paneDiff
m.width = 200
m.collapsed.enabled = true
m.collapsed.expandedHunks = make(map[int]bool)
m.diffCursor = 2

t.Run("collapsed mode on hunk shows expand hunk hint", func(t *testing.T) {
m.collapsed.enabled = true
m.collapsed.expandedHunks = make(map[int]bool)
m.diffCursor = 2 // on add line in hunk
status := m.statusBarText(m.annotatedFiles())
assert.Contains(t, status, "[.] expand hunk")
assert.NotContains(t, status, "[.] collapse hunk")
})

t.Run("collapsed mode on expanded hunk shows collapse hunk hint", func(t *testing.T) {
m.collapsed.enabled = true
m.collapsed.expandedHunks = map[int]bool{1: true} // hunk starts at index 1
m.diffCursor = 2 // on add line in expanded hunk
status := m.statusBarText(m.annotatedFiles())
assert.Contains(t, status, "[.] collapse hunk")
assert.NotContains(t, status, "[.] expand hunk")
})

t.Run("collapsed mode on context line hides dot hint", func(t *testing.T) {
m.collapsed.enabled = true
m.collapsed.expandedHunks = make(map[int]bool)
m.diffCursor = 0 // on context line
status := m.statusBarText(m.annotatedFiles())
assert.NotContains(t, status, "[.]")
})

t.Run("expanded mode hides dot hint", func(t *testing.T) {
m.collapsed.enabled = false
m.diffCursor = 2 // on changed line, but not collapsed
status := m.statusBarText(m.annotatedFiles())
assert.NotContains(t, status, "[.]")
})
status := m.statusBarText()
// shortcut hints are moved to help overlay, not in status line
assert.NotContains(t, status, "[.]")
assert.NotContains(t, status, "[v]")
}

func TestModel_CollapsedCursorToEndSkipsRemovedLines(t *testing.T) {
Expand Down
38 changes: 27 additions & 11 deletions ui/filetree.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"path/filepath"
"sort"
"strings"

"github.com/charmbracelet/lipgloss"
)

// fileTree manages the list of changed files grouped by directory.
Expand Down Expand Up @@ -235,17 +237,7 @@ func (ft *fileTree) render(width, height int, annotatedFiles map[string]bool, s
if e.isDir {
line = s.DirEntry.Render(" " + ft.truncateDirName(e.name, width-3))
} else {
marker := " "
if annotatedFiles[e.path] {
marker = s.AnnotationMark.Render(" *")
}
name := indent + e.name + marker

if idx == ft.cursor {
line = s.FileSelected.Width(width - 2).Render(name)
} else {
line = s.FileEntry.Render(name)
}
line = ft.renderFileEntry(e, idx, indent, width, annotatedFiles, s)
}

b.WriteString(line)
Expand All @@ -256,6 +248,30 @@ func (ft *fileTree) render(width, height int, annotatedFiles map[string]bool, s
return b.String()
}

// renderFileEntry renders a single file entry in the tree, truncating long names to prevent wrapping.
func (ft *fileTree) renderFileEntry(e treeEntry, idx int, indent string, width int, annotatedFiles map[string]bool, s styles) string {
marker := " "
if annotatedFiles[e.path] {
marker = s.AnnotationMark.Render(" *")
}
name := indent + e.name + marker
maxWidth := width - 2

// truncate from the left of the filename when it exceeds pane width
if lipgloss.Width(name) > maxWidth && maxWidth > 4 {
budget := maxWidth - lipgloss.Width(indent) - lipgloss.Width(marker) - 1 // 1 for "…"
if budget > 0 && lipgloss.Width(e.name) > budget {
runes := []rune(e.name)
name = indent + "…" + string(runes[len(runes)-budget+1:]) + marker
}
}

if idx == ft.cursor {
return s.FileSelected.Width(maxWidth).Render(name)
}
return s.FileEntry.Render(name)
}

// filterFiles returns the subset of allFiles that have annotations.
func (ft *fileTree) filterFiles(annotatedFiles map[string]bool) []string {
var filtered []string
Expand Down
Loading
Loading