Skip to content

Add visual range selection (V) and hunk annotation (H)#43

Open
melonamin wants to merge 4 commits intoumputun:masterfrom
melonamin:feature/range-hunk-annotations
Open

Add visual range selection (V) and hunk annotation (H)#43
melonamin wants to merge 4 commits intoumputun:masterfrom
melonamin:feature/range-hunk-annotations

Conversation

@melonamin
Copy link
Copy Markdown
Contributor

@melonamin melonamin commented Apr 6, 2026

Summary

  • Visual range selection (V) — select a span of diff lines, confirm with Enter to annotate the range
  • Hunk annotation (H) — one-key annotation of the entire hunk under the cursor

Demo

screenrecording-2026-04-06_17-28-46.mp4

Show author name and relative commit age per line in a gutter column,
toggled with the B key. Blame data loads asynchronously via git blame
and is keyed by new-side line numbers (blank for removed lines/dividers).

- Add diff/blame.go with FileBlame parser and RelativeAge formatter
- Add Blamer interface in ui/, wired via ModelConfig from cmd/main.go
- Add blameGutter rendering in diffview.go alongside lineNumGutter
- Extract shared gutter helpers (lineGutters, gutterExtra, gutterBlanks,
  applyHorizontalScroll) to reduce duplication across render paths
- Add ActionToggleBlame keymap action bound to B
- Update status bar mode icons to show @ when blame is active
- Update README, docs, site, CLAUDE.md, and plugin references
Range annotations let users select a line range with V or target an
entire hunk with H, then attach a single annotation to the whole
region. Ranges render with ┌│└ gutter indicators shown live during
selection and while typing. Annotations appear below the range end,
support cursor stops for edit/delete, and output as file:start-end
in structured format.

- Extend Annotation store with EndLine field, overlap rejection,
  HasRangeCovering/GetRangeCovering interval lookups
- Add ActionSelectRange (V) and ActionAnnotateHunk (H) to keymap
- Wire selection mode with live ┌│└ gutter, cursor movement skips
  annotation stops during selection
- Range annotation CRUD: create, edit, delete, pre-fill existing
- Annotation list shows ranges as file:N-M with annotation color
- Status bar shows ▋ icon and SEL: N lines during selection
- Update README, site/docs.html, and plugin reference docs
…navigation

visibleRangeIndices now breaks at ChangeDivider once a range has started,
preventing line-number aliasing from leaking ranges into adjacent hunks.

Add cursorOnRangeAnnotation field so point and range-end annotations on the
same diff line are independently reachable via cursor movement, Enter, and
delete. Cursor indicators and viewport Y calculation dispatch correctly for
each sub-row.
@melonamin melonamin requested a review from umputun as a code owner April 6, 2026 22:00
Drop SelectionHighlight background items (gutter indicator used instead),
correct hunk single-line detection description, add Task 8 for post-review
bug fixes.
Copy link
Copy Markdown
Owner

@umputun umputun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the implementation is solid, good tests, clean data model. but I have a fundamental concern about whether this is worth the complexity.

do we need range/hunk annotations? the current single-line annotation already gives enough context. when someone writes ## file.go:43 (+) refactor this block, the AI reading the annotations can see the surrounding code and infer the scope. I don't see a real use case where pointing at a specific line isn't sufficient. this adds ~2000 lines (including tests) for what seems like a nice-to-have.

as a user I love how the visual selection looks, it's a nice UX. but as a maintainer, 2k lines of new code to maintain for a feature without a clear problem statement is a concern.

I'd like to understand what problem you ran into that line annotations couldn't solve. if there's a concrete use case I'm missing, happy to reconsider.

if we do proceed, a few things to fix:

  1. theme/bundled.go has 5 stray hex values without key names (bare #44475a etc.). they're parsed as comments and silently ignored, so SelectedBg is never set in any bundled theme. these need to be proper color-selected-bg = #hex entries, or removed if the selection background approach was dropped in favor of gutter indicators
  2. whitespace-only alignment changes in cmd/revdiff/main.go, theme/theme.go, ui/styles.go that aren't related to the feature. pls revert those
  3. A (file-level annotation) should be blocked during selection mode, like point annotation is

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds range-based annotations to the TUI, including a visual selection mode (V) and a one-key “annotate hunk” shortcut (H), plus rendering/cursoring support and updated docs/tests.

Changes:

  • Implement visual range selection and hunk-wide annotation workflows in the diff pane.
  • Add range annotation persistence/rendering (gutter + end-of-range comment row) and cursor navigation across point/range sub-rows.
  • Update keybindings/help/docs and expand test coverage across UI, collapsed mode, and annotation store.

Reviewed changes

Copilot reviewed 18 out of 21 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
ui/styles.go Minor struct field alignment change for search colors.
ui/search.go Clears new range-cursor state when jumping between search matches.
ui/model.go Adds selection/range state to the model; hooks new actions into navigation/status rendering.
ui/model_test.go Updates tests for new store API + adds extensive selection/range/hunk behavior tests.
ui/diffview.go Implements range gutter + range annotation rendering; updates wrapping and cursor nav to account for range rows.
ui/collapsed.go Threads range gutter/rendering through collapsed diff rendering paths.
ui/collapsed_test.go Adds coverage for collapsed rendering/wrapping with range annotations.
ui/annotlist.go Formats range annotations in the list and supports jumping to ranges.
ui/annotlist_test.go Adds test for range formatting in annotation list.
ui/annotate.go Implements selection mode, range annotation lifecycle, hunk annotation, and range-aware delete/edit behavior.
theme/theme.go Minor formatting change in optional color keys map.
theme/bundled.go Adds trailing theme comment lines (currently appear to be accidental/ineffective).
site/docs.html Documents V/H and range output format.
README.md Documents V/H and range output format.
keymap/keymap.go Adds new actions and default bindings for V and H.
keymap/keymap_test.go Updates default binding expectations for new actions.
docs/plans/2026-04-06-range-hunk-annotations.md Adds implementation plan / checklist for the feature.
cmd/revdiff/main.go Minor formatting around search color flag fields/mapping.
annotation/store.go Extends annotations with EndLine, adds range overlap logic, and updates delete identity.
annotation/store_test.go Adds/updates tests for new range annotation semantics and store API.
.claude-plugin/skills/revdiff/references/usage.md Updates usage docs with V/H and range output format.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

color-status-bg = #bd93f9
color-search-fg = #282a36
color-search-bg = #f1fa8c
#44475a
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trailing #44475a line is treated as a comment by theme parsing and therefore has no effect. If this was meant to change a color value (e.g. color-selected-bg), it needs to be a proper key = value entry; otherwise it should be removed to avoid shipping confusing theme files.

Suggested change
#44475a

Copilot uses AI. Check for mistakes.
color-status-bg = #88c0d0
color-search-fg = #2e3440
color-search-bg = #ebcb8b
#3b4252
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trailing #3b4252 line is treated as a comment by theme parsing and therefore has no effect. If this was meant to change a color value, it needs to be a proper key = value entry; otherwise it should be removed to avoid shipping confusing theme files.

Suggested change
#3b4252

Copilot uses AI. Check for mistakes.
Comment on lines 83 to 88
color-status-fg = #002b36
color-status-bg = #b58900
color-search-fg = #002b36
color-search-bg = #cb4b16
#073642
`
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trailing #073642 line is treated as a comment by theme parsing and therefore has no effect. If this was meant to change a color value, it needs to be a proper key = value entry; otherwise it should be removed to avoid shipping confusing theme files.

Copilot uses AI. Check for mistakes.
Comment on lines 111 to 116
color-status-fg = #1e1e2e
color-status-bg = #89b4fa
color-search-fg = #1e1e2e
color-search-bg = #f9e2af
#313244
`
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trailing #313244 line is treated as a comment by theme parsing and therefore has no effect. If this was meant to change a color value, it needs to be a proper key = value entry; otherwise it should be removed to avoid shipping confusing theme files.

Copilot uses AI. Check for mistakes.
color-status-bg = #fabd2f
color-search-fg = #282828
color-search-bg = #fe8019
#3c3836
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trailing #3c3836 line is treated as a comment by theme parsing and therefore has no effect. If this was meant to change a color value, it needs to be a proper key = value entry; otherwise it should be removed to avoid shipping confusing theme files.

Suggested change
#3c3836

Copilot uses AI. Check for mistakes.
Comment on lines +251 to +259
// styledRangeGutter returns the styled range gutter indicator for a diffLines index.
func (m Model) styledRangeGutter(idx int, ranges []rangeRenderInfo) string {
gutter := rangeGutterFor(idx, ranges)
if gutter == "" {
if len(ranges) > 0 {
return strings.Repeat(" ", rangeGutterWidth)
}
return ""
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

styledRangeGutter only reserves rangeGutterWidth spaces when len(ranges) > 0. Other parts of rendering/measurement (e.g. wrappedLineCount via hasRangeGutter) can decide a range gutter is “active” even when ranges is empty (e.g. stored ranges that don't map to visible indices), which can desync wrapping/cursor calculations. Consider deriving a single showRangeGutter boolean and using it consistently for both width reservation and wrap-width calculations.

Copilot uses AI. Check for mistakes.
Comment on lines +333 to +338
rgBlank := ""
if len(ranges) > 0 {
rgBlank = strings.Repeat(" ", rangeGutterWidth)
}
wrapWidth := m.diffContentWidth() - wrapGutterWidth - m.gutterExtra()
if len(ranges) > 0 {
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderWrappedDiffLine adjusts wrapWidth based on len(ranges) > 0, but wrappedLineCount adjusts based on hasRangeGutter(). These predicates can diverge (e.g. range annotations exist in the store but buildRangeAnnotations returns none), causing wrap rendering and wrap measurement to disagree. Use a single predicate for both code paths so wrapped-line counts stay in sync with actual rendering.

Suggested change
rgBlank := ""
if len(ranges) > 0 {
rgBlank = strings.Repeat(" ", rangeGutterWidth)
}
wrapWidth := m.diffContentWidth() - wrapGutterWidth - m.gutterExtra()
if len(ranges) > 0 {
showRangeGutter := m.hasRangeGutter()
rgBlank := ""
if showRangeGutter {
rgBlank = strings.Repeat(" ", rangeGutterWidth)
}
wrapWidth := m.diffContentWidth() - wrapGutterWidth - m.gutterExtra()
if showRangeGutter {

Copilot uses AI. Check for mistakes.
Comment on lines 377 to 382
_, textContent, _ := m.prepareLineContent(idx, dl)
wrapWidth := m.diffContentWidth() - wrapGutterWidth - m.gutterExtra()
if m.hasRangeGutter() {
wrapWidth -= rangeGutterWidth
}
return len(m.wrapContent(textContent, wrapWidth))
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrappedLineCount calls hasRangeGutter() (which scans the store) for every line measured. This is on hot paths like cursorViewportY() and can become O(lines × annotations). Consider computing the “range gutter active” boolean once per render/frame and passing it into wrappedLineCount, or caching it on the model for the duration of a render.

Copilot uses AI. Check for mistakes.
Comment on lines +405 to +414
// isRangeAnnotationEnd returns true if the given diffLines index is the end of a saved range annotation.
func (m Model) isRangeAnnotationEnd(idx int) bool {
for _, r := range m.buildRangeAnnotations() {
if r.endIdx == idx {
return true
}
}
return false
}

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRangeAnnotationEnd rebuilds the full range annotation mapping (buildRangeAnnotations + visibleRangeIndices scan) on every cursor-move check. Since this is called from moveDiffCursorUp/Down, it can add noticeable latency on large diffs. Consider caching range end indices (e.g. a map[endIdx]bool) alongside the precomputed ranges used for rendering, and have navigation use that cached structure instead of recomputing.

Suggested change
// isRangeAnnotationEnd returns true if the given diffLines index is the end of a saved range annotation.
func (m Model) isRangeAnnotationEnd(idx int) bool {
for _, r := range m.buildRangeAnnotations() {
if r.endIdx == idx {
return true
}
}
return false
}
type rangeAnnotationEndCacheEntry struct {
signature string
ends map[int]bool
}
var rangeAnnotationEndCache = map[string]rangeAnnotationEndCacheEntry{}
// rangeAnnotationCacheSignature returns a stable signature for the current file's saved annotations.
// The cache is rebuilt whenever the current file or its annotations change.
func (m Model) rangeAnnotationCacheSignature() string {
anns := m.store.Get(m.currFile)
sig := m.currFile
for _, a := range anns {
sig += fmt.Sprintf("|%d|%d|%s", a.Line, a.EndLine, a.Text)
}
return sig
}
// rangeAnnotationEnds returns a cached set of diffLines indices that terminate saved range annotations.
func (m Model) rangeAnnotationEnds() map[int]bool {
sig := m.rangeAnnotationCacheSignature()
if entry, ok := rangeAnnotationEndCache[m.currFile]; ok && entry.signature == sig {
return entry.ends
}
ends := make(map[int]bool)
for _, r := range m.buildRangeAnnotations() {
ends[r.endIdx] = true
}
rangeAnnotationEndCache[m.currFile] = rangeAnnotationEndCacheEntry{
signature: sig,
ends: ends,
}
return ends
}
// isRangeAnnotationEnd returns true if the given diffLines index is the end of a saved range annotation.
func (m Model) isRangeAnnotationEnd(idx int) bool {
return m.rangeAnnotationEnds()[idx]
}

Copilot uses AI. Check for mistakes.
@melonamin
Copy link
Copy Markdown
Contributor Author

I'd like to understand what problem you ran into that line annotations couldn't solve. if there's a concrete use case I'm missing, happy to reconsider.

In the last few days, I ran into a problem when the agent got confused and didn't do what I meant. Most likely, this is a skill issue on my end. My hunch, based on my experience building agents, is to provide as much context as possible and to be as specific as possible; that's why I decided to prototype this feature.

Since I just built it, I have no idea if it fits into my workflow yet. I like how it turned out visually, but I didn't expect that it would be THAT complex, so I've been second-guessing as well.

How about this - let it hang for a week; if I find a strong use case, I will report back. Meanwhile, maybe you or others will play with it too.

@umputun
Copy link
Copy Markdown
Owner

umputun commented Apr 6, 2026

How about this - let it hang for a week; if I find a strong use case, I will report back. Meanwhile, maybe you or others will play with it too.

sounds good to me

@umputun
Copy link
Copy Markdown
Owner

umputun commented Apr 6, 2026

btw, had an idea for a much simpler alternative that might cover the main use case.

instead of visual selection, detect keywords like "hunk" or "block" in the annotation text and automatically expand the output to include the hunk range. so if a user writes refactor this hunk on line 43, the output becomes ## file.go:43-67 (+) instead of ## file.go:43 (+). the AI consumer gets the range context it needs without any new UI.

the hunk boundary detection already exists (findHunks / hunkStartFor), so this would be ~30-50 lines of code vs ~2000. no new modes, no cursor state, no rendering changes.

not saying your PR should be closed, just thinking out loud about the cost/benefit tradeoff.

@umputun
Copy link
Copy Markdown
Owner

umputun commented Apr 7, 2026

btw, I went ahead and tried the simpler alternative I mentioned earlier. merged in #47, ended up being ~50 lines of actual logic.

if the annotation text contains the word "hunk", the output header automatically expands to include the line range, e.g. ## file.go:43-67 (+) instead of ## file.go:43 (+). the AI consumer gets the range context without any new UI. no new modes, no cursor state, no rendering changes.

wanted to see how far the simple approach goes first, and the added code seems worth keeping even if the use case isn't fully clear yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants