Skip to content

Commit 63f1bd7

Browse files
committed
feat: document hunk keyword expansion in output format
1 parent 93dce45 commit 63f1bd7

9 files changed

Lines changed: 194 additions & 19 deletions

File tree

.claude-plugin/skills/revdiff/references/usage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,15 @@ consider splitting this file into smaller modules
143143
## handler.go:43 (+)
144144
use errors.Is() instead of direct comparison
145145
146+
## handler.go:43-67 (+)
147+
refactor this hunk to reduce nesting
148+
146149
## store.go:18 (-)
147150
don't remove this validation
148151
```
149152

150153
Each annotation block: `## filename:line (type)` where type is `(+)` added, `(-)` removed, or `(file-level)`.
151154

155+
When annotation text contains keywords "hunk" or "block" (case-insensitive, whole word), the output header automatically expands to include the full hunk line range (e.g., `handler.go:43-67 (+)` instead of `handler.go:43 (+)`). This gives AI consumers the range context without any extra steps.
156+
152157
Use `--output` / `-o` flag to write annotations to a file instead of stdout.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ TUI for reviewing diffs, files, and documents with inline annotations, built wit
1717
- `highlight/` - chroma-based syntax highlighting, foreground-only ANSI output
1818
- `keymap/` - user-configurable keybindings (`Action` constants, `Keymap` type, parser, defaults, dump)
1919
- `theme/` - color theme system: Parse (with hex validation), Load, List, Dump, InitBundled, BundledNames, ColorKeys (bundled: dracula, nord, solarized-dark)
20-
- `annotation/` - in-memory annotation store, structured output formatting
20+
- `annotation/` - in-memory annotation store, structured output formatting; `Annotation.EndLine` enables hunk range headers when comment contains "hunk"/"block" keywords
2121
- `ui/mocks/` - moq-generated mocks (never edit manually)
2222

2323
## Key Interfaces (consumer-side, in `ui/`)

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,10 +499,15 @@ consider splitting this file into smaller modules
499499
## handler.go:43 (+)
500500
use errors.Is() instead of direct comparison
501501
502+
## handler.go:43-67 (+)
503+
refactor this hunk to reduce nesting
504+
502505
## store.go:18 (-)
503506
don't remove this validation
504507
```
505508

509+
When annotation text contains keywords "hunk" or "block" (case-insensitive, whole word), the output header automatically expands to include the full hunk line range (e.g., `handler.go:43-67 (+)` instead of `handler.go:43 (+)`). This gives AI consumers the range context without any extra steps.
510+
506511
## Contributing
507512

508513
See [CONTRIBUTING.md](CONTRIBUTING.md) for details.

annotation/store.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func (s *Store) Add(a Annotation) {
3131
existing := s.annotations[a.File]
3232
if i, ok := s.find(a.File, a.Line, a.Type); ok {
3333
existing[i].Comment = a.Comment
34+
existing[i].EndLine = a.EndLine
3435
return
3536
}
3637
s.annotations[a.File] = append(existing, a)

annotation/store_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ func TestStore_AddReplacesExisting(t *testing.T) {
2727
assert.Equal(t, "new comment", anns[0].Comment)
2828
}
2929

30+
func TestStore_AddReplacesExistingPreservesEndLine(t *testing.T) {
31+
s := NewStore()
32+
s.Add(Annotation{File: "handler.go", Line: 43, Type: "+", Comment: "old comment", EndLine: 0})
33+
s.Add(Annotation{File: "handler.go", Line: 43, Type: "+", Comment: "refactor this hunk", EndLine: 67})
34+
35+
anns := s.Get("handler.go")
36+
require.Len(t, anns, 1)
37+
assert.Equal(t, "refactor this hunk", anns[0].Comment)
38+
assert.Equal(t, 67, anns[0].EndLine, "EndLine should be updated on replacement")
39+
}
40+
41+
func TestStore_AddReplacesExistingClearsEndLine(t *testing.T) {
42+
s := NewStore()
43+
s.Add(Annotation{File: "handler.go", Line: 43, Type: "+", Comment: "refactor this hunk", EndLine: 67})
44+
s.Add(Annotation{File: "handler.go", Line: 43, Type: "+", Comment: "just a note", EndLine: 0})
45+
46+
anns := s.Get("handler.go")
47+
require.Len(t, anns, 1)
48+
assert.Equal(t, "just a note", anns[0].Comment)
49+
assert.Equal(t, 0, anns[0].EndLine, "EndLine should be cleared when replacement has no range")
50+
}
51+
3052
func TestStore_AddMultipleLines(t *testing.T) {
3153
s := NewStore()
3254
s.Add(Annotation{File: "handler.go", Line: 43, Type: "+", Comment: "first"})

docs/plans/20260406-hunk-keyword-expansion.md renamed to docs/plans/completed/20260406-hunk-keyword-expansion.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ Add an `EndLine` field to `Annotation`. When creating an annotation, if the comm
5050

5151
### Task 3: Verify and document
5252

53-
- [ ] run full test suite: `make test`
54-
- [ ] run linter: `make lint`
55-
- [ ] manual test: annotate a line with "fix this hunk", verify output shows range
56-
- [ ] update README.md output format section to mention range expansion
57-
- [ ] update site/docs.html output format section
58-
- [ ] update CLAUDE.md if needed
59-
- [ ] move this plan to `docs/plans/completed/`
53+
- [x] run full test suite: `make test`
54+
- [x] run linter: `make lint`
55+
- [x] manual test (skipped - not automatable)
56+
- [x] update README.md output format section to mention range expansion
57+
- [x] update site/docs.html output format section
58+
- [x] update CLAUDE.md if needed
59+
- [x] move this plan to `docs/plans/completed/`

site/docs.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,9 +431,13 @@ <h2 id="output-format">Output format</h2>
431431
<span class="code-comment">## handler.go:43 (+)</span>
432432
use errors.Is() instead of direct comparison
433433

434+
<span class="code-comment">## handler.go:43-67 (+)</span>
435+
refactor this hunk to reduce nesting
436+
434437
<span class="code-comment">## store.go:18 (-)</span>
435438
don't remove this validation</code></div>
436439
<p>Each annotation block: <code>## filename:line (type)</code> where type is <code>(+)</code> added, <code>(-)</code> removed, or <code>(file-level)</code>.</p>
440+
<p>When annotation text contains keywords &ldquo;hunk&rdquo; or &ldquo;block&rdquo; (case-insensitive, whole word), the output header automatically expands to include the full hunk line range (e.g., <code>handler.go:43-67 (+)</code> instead of <code>handler.go:43 (+)</code>). This gives AI consumers the range context without any extra steps.</p>
437441

438442
<h2 id="integration">Integration with other tools</h2>
439443
<p>The structured stdout output works with any tool that can read text:</p>

ui/annotate.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,9 @@ func (m Model) diffLineNum(dl diff.DiffLine) int {
232232
}
233233

234234
// hunkEndLine returns the display line number of the last line in the change hunk
235-
// containing diffLines[idx]. returns 0 if idx is not inside a change hunk.
235+
// containing diffLines[idx]. only walks forward through lines of the same change type
236+
// as the starting line, so both start and end use the same number space (old or new).
237+
// returns 0 if idx is not inside a change hunk.
236238
func (m Model) hunkEndLine(idx int) int {
237239
if idx < 0 || idx >= len(m.diffLines) {
238240
return 0
@@ -242,11 +244,11 @@ func (m Model) hunkEndLine(idx int) int {
242244
return 0
243245
}
244246

245-
// walk forward from idx to find the last contiguous change line
247+
// walk forward from idx to find the last contiguous line of the same change type
248+
startType := dl.ChangeType
246249
last := idx
247250
for i := idx + 1; i < len(m.diffLines); i++ {
248-
ct := m.diffLines[i].ChangeType
249-
if ct != diff.ChangeAdd && ct != diff.ChangeRemove {
251+
if m.diffLines[i].ChangeType != startType {
250252
break
251253
}
252254
last = i

ui/model_test.go

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -963,20 +963,20 @@ func TestModel_AnnotateHunkKeywordSetsEndLine(t *testing.T) {
963963
assert.Equal(t, 3, anns[0].EndLine, "EndLine should be last add line's NewNum")
964964
})
965965

966-
t.Run("block keyword populates EndLine", func(t *testing.T) {
966+
t.Run("block keyword on single remove in mixed hunk", func(t *testing.T) {
967967
m := testModel([]string{"a.go"}, nil)
968968
m.tree = newFileTree([]string{"a.go"})
969969
m.focus = paneDiff
970970
m.currFile = "a.go"
971971
m.diffLines = lines
972-
m.diffCursor = 1 // on "old line" (remove, OldNum=2)
972+
m.diffCursor = 1 // on "old line" (remove, OldNum=2) — only one remove in this hunk
973973
m.startAnnotation()
974974
m.annotateInput.SetValue("review this BLOCK carefully")
975975
m.saveAnnotation()
976976
anns := m.store.Get("a.go")
977977
require.Len(t, anns, 1)
978978
assert.Equal(t, 2, anns[0].Line)
979-
assert.Equal(t, 3, anns[0].EndLine, "EndLine should be last add line's NewNum")
979+
assert.Equal(t, 0, anns[0].EndLine, "single remove line, no multi-line range")
980980
})
981981

982982
t.Run("no keyword does not set EndLine", func(t *testing.T) {
@@ -1021,11 +1021,11 @@ func TestModel_HunkEndLine(t *testing.T) {
10211021
m := testModel(nil, nil)
10221022
m.diffLines = lines
10231023

1024-
t.Run("returns last line of hunk from first change", func(t *testing.T) {
1025-
assert.Equal(t, 3, m.hunkEndLine(1), "hunk ending at add NewNum=3")
1024+
t.Run("remove line stops at same type boundary", func(t *testing.T) {
1025+
assert.Equal(t, 2, m.hunkEndLine(1), "single remove stays in old-file number space")
10261026
})
1027-
t.Run("returns last line from middle of hunk", func(t *testing.T) {
1028-
assert.Equal(t, 3, m.hunkEndLine(2))
1027+
t.Run("add line walks through consecutive adds", func(t *testing.T) {
1028+
assert.Equal(t, 3, m.hunkEndLine(2), "two adds, last NewNum=3")
10291029
})
10301030
t.Run("returns last line from last change line", func(t *testing.T) {
10311031
assert.Equal(t, 3, m.hunkEndLine(3))
@@ -1037,6 +1037,142 @@ func TestModel_HunkEndLine(t *testing.T) {
10371037
assert.Equal(t, 0, m.hunkEndLine(-1))
10381038
assert.Equal(t, 0, m.hunkEndLine(99))
10391039
})
1040+
1041+
t.Run("mixed hunk removes followed by adds", func(t *testing.T) {
1042+
// simulates a real diff where 3 lines are removed and 2 added
1043+
mixedLines := []diff.DiffLine{
1044+
{OldNum: 10, NewNum: 10, Content: "ctx", ChangeType: diff.ChangeContext},
1045+
{OldNum: 11, Content: "old line 1", ChangeType: diff.ChangeRemove},
1046+
{OldNum: 12, Content: "old line 2", ChangeType: diff.ChangeRemove},
1047+
{OldNum: 13, Content: "old line 3", ChangeType: diff.ChangeRemove},
1048+
{NewNum: 11, Content: "new line 1", ChangeType: diff.ChangeAdd},
1049+
{NewNum: 12, Content: "new line 2", ChangeType: diff.ChangeAdd},
1050+
{OldNum: 14, NewNum: 13, Content: "ctx", ChangeType: diff.ChangeContext},
1051+
}
1052+
m.diffLines = mixedLines
1053+
1054+
// cursor on first remove: end should be last remove (OldNum=13), not any add line
1055+
assert.Equal(t, 13, m.hunkEndLine(1), "remove hunk end stays in old-file space")
1056+
// cursor on middle remove
1057+
assert.Equal(t, 13, m.hunkEndLine(2), "middle remove walks to last remove")
1058+
// cursor on last remove
1059+
assert.Equal(t, 13, m.hunkEndLine(3), "last remove returns own OldNum")
1060+
1061+
// cursor on first add: end should be last add (NewNum=12), not a remove
1062+
assert.Equal(t, 12, m.hunkEndLine(4), "add hunk end stays in new-file space")
1063+
// cursor on last add
1064+
assert.Equal(t, 12, m.hunkEndLine(5), "last add returns own NewNum")
1065+
})
1066+
}
1067+
1068+
func TestModel_AnnotateReAnnotateKeywordChange(t *testing.T) {
1069+
lines := []diff.DiffLine{
1070+
{OldNum: 1, NewNum: 1, Content: "ctx before", ChangeType: diff.ChangeContext},
1071+
{NewNum: 2, Content: "new line", ChangeType: diff.ChangeAdd},
1072+
{NewNum: 3, Content: "added line", ChangeType: diff.ChangeAdd},
1073+
{OldNum: 2, NewNum: 4, Content: "ctx after", ChangeType: diff.ChangeContext},
1074+
}
1075+
1076+
t.Run("add keyword to existing annotation updates EndLine", func(t *testing.T) {
1077+
m := testModel([]string{"a.go"}, nil)
1078+
m.tree = newFileTree([]string{"a.go"})
1079+
m.focus = paneDiff
1080+
m.currFile = "a.go"
1081+
m.diffLines = lines
1082+
m.diffCursor = 1 // on "new line" (add, NewNum=2)
1083+
m.startAnnotation()
1084+
m.annotateInput.SetValue("this is fine")
1085+
m.saveAnnotation()
1086+
anns := m.store.Get("a.go")
1087+
require.Len(t, anns, 1)
1088+
assert.Equal(t, 0, anns[0].EndLine, "initially no EndLine")
1089+
1090+
// re-annotate same line with hunk keyword
1091+
m.startAnnotation()
1092+
m.annotateInput.SetValue("refactor this hunk")
1093+
m.saveAnnotation()
1094+
anns = m.store.Get("a.go")
1095+
require.Len(t, anns, 1)
1096+
assert.Equal(t, "refactor this hunk", anns[0].Comment)
1097+
assert.Equal(t, 3, anns[0].EndLine, "EndLine should be set after adding keyword")
1098+
})
1099+
1100+
t.Run("remove keyword from existing annotation clears EndLine", func(t *testing.T) {
1101+
m := testModel([]string{"a.go"}, nil)
1102+
m.tree = newFileTree([]string{"a.go"})
1103+
m.focus = paneDiff
1104+
m.currFile = "a.go"
1105+
m.diffLines = lines
1106+
m.diffCursor = 1
1107+
m.startAnnotation()
1108+
m.annotateInput.SetValue("refactor this hunk")
1109+
m.saveAnnotation()
1110+
anns := m.store.Get("a.go")
1111+
require.Len(t, anns, 1)
1112+
assert.Equal(t, 3, anns[0].EndLine, "initially has EndLine")
1113+
1114+
// re-annotate same line without keyword
1115+
m.startAnnotation()
1116+
m.annotateInput.SetValue("just a note")
1117+
m.saveAnnotation()
1118+
anns = m.store.Get("a.go")
1119+
require.Len(t, anns, 1)
1120+
assert.Equal(t, "just a note", anns[0].Comment)
1121+
assert.Equal(t, 0, anns[0].EndLine, "EndLine should be cleared after removing keyword")
1122+
})
1123+
}
1124+
1125+
func TestModel_AnnotateSingleLineHunkWithKeyword(t *testing.T) {
1126+
// single change line: hunkEndLine returns the same lineNum, so endLine > lineNum is false
1127+
lines := []diff.DiffLine{
1128+
{OldNum: 1, NewNum: 1, Content: "ctx before", ChangeType: diff.ChangeContext},
1129+
{NewNum: 2, Content: "single add", ChangeType: diff.ChangeAdd},
1130+
{OldNum: 2, NewNum: 3, Content: "ctx after", ChangeType: diff.ChangeContext},
1131+
}
1132+
1133+
m := testModel([]string{"a.go"}, nil)
1134+
m.tree = newFileTree([]string{"a.go"})
1135+
m.focus = paneDiff
1136+
m.currFile = "a.go"
1137+
m.diffLines = lines
1138+
m.diffCursor = 1 // on "single add" (add, NewNum=2)
1139+
m.startAnnotation()
1140+
m.annotateInput.SetValue("refactor this hunk")
1141+
m.saveAnnotation()
1142+
1143+
anns := m.store.Get("a.go")
1144+
require.Len(t, anns, 1)
1145+
assert.Equal(t, 2, anns[0].Line)
1146+
assert.Equal(t, 0, anns[0].EndLine, "single-line hunk should not set EndLine (avoids 2-2 range)")
1147+
}
1148+
1149+
func TestModel_AnnotateRemovedLineInMixedHunk(t *testing.T) {
1150+
// mixed hunk: 2 removes followed by 2 adds — annotating a remove line with hunk keyword
1151+
// should produce EndLine in the old-file number space, not crossing into the add lines
1152+
lines := []diff.DiffLine{
1153+
{OldNum: 10, NewNum: 10, Content: "ctx before", ChangeType: diff.ChangeContext},
1154+
{OldNum: 11, Content: "removed 1", ChangeType: diff.ChangeRemove},
1155+
{OldNum: 12, Content: "removed 2", ChangeType: diff.ChangeRemove},
1156+
{NewNum: 11, Content: "added 1", ChangeType: diff.ChangeAdd},
1157+
{NewNum: 12, Content: "added 2", ChangeType: diff.ChangeAdd},
1158+
{OldNum: 13, NewNum: 13, Content: "ctx after", ChangeType: diff.ChangeContext},
1159+
}
1160+
1161+
m := testModel([]string{"a.go"}, nil)
1162+
m.tree = newFileTree([]string{"a.go"})
1163+
m.focus = paneDiff
1164+
m.currFile = "a.go"
1165+
m.diffLines = lines
1166+
m.diffCursor = 1 // on first remove (OldNum=11)
1167+
m.startAnnotation()
1168+
m.annotateInput.SetValue("fix this hunk")
1169+
m.saveAnnotation()
1170+
1171+
anns := m.store.Get("a.go")
1172+
require.Len(t, anns, 1)
1173+
assert.Equal(t, 11, anns[0].Line, "start line is OldNum of first remove")
1174+
assert.Equal(t, 12, anns[0].EndLine, "end line is OldNum of last remove, not NewNum of an add")
1175+
assert.Equal(t, string(diff.ChangeRemove), anns[0].Type)
10401176
}
10411177

10421178
func TestModel_AnnotateEscCancels(t *testing.T) {

0 commit comments

Comments
 (0)