Skip to content
Merged
7 changes: 6 additions & 1 deletion .claude-plugin/skills/revdiff/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: revdiff
description: Review diffs, files, and documents with inline annotations in a TUI overlay, or answer questions about revdiff usage, configuration, themes, and keybindings. Opens revdiff in tmux/zellij/kitty/wezterm/cmux/ghostty/iterm2/emacs-vterm, captures annotations, and addresses them. Works in git, hg, and jj repos (auto-detected). Activates on "revdiff", "review diff", "review changes", "annotate diff", "git review with revdiff", "hg review with revdiff", "review jj change", "interactive diff review", "revdiff all files", "review all files", "browse all files", "revdiff <file>", "revdiff README.md", "revdiff /tmp/notes.txt", "review this file", "annotate this file", "review file with revdiff", "revdiff config", "revdiff themes", "revdiff keybindings", "how to configure revdiff", "what themes does revdiff have".
description: Review diffs, files, and documents with inline annotations in a TUI overlay, or answer questions about revdiff usage, configuration, themes, and keybindings. Opens revdiff in tmux/zellij/kitty/wezterm/cmux/ghostty/iterm2/emacs-vterm, captures annotations, and addresses them. Works in git, hg, and jj repos (auto-detected). Activates on "revdiff", "review diff", "review changes", "annotate diff", "git review with revdiff", "hg review with revdiff", "review jj change", "interactive diff review", "revdiff all files", "review all files", "browse all files", "revdiff <file>", "revdiff README.md", "revdiff /tmp/notes.txt", "review this file", "annotate this file", "review file with revdiff", "open this review in revdiff", "show review in revdiff", "review in revdiff", "revdiff config", "revdiff themes", "revdiff keybindings", "how to configure revdiff", "what themes does revdiff have".
argument-hint: 'optional: ref(s), "all files", or file path'
allowed-tools: [Bash, Read, Edit, Write, Grep, Glob]
---
Expand All @@ -18,6 +18,7 @@ Review diffs with inline annotations using revdiff TUI in a terminal overlay. Wo
- "revdiff all files exclude vendor"
- "revdiff README.md", "revdiff docs/plan.md", "revdiff /tmp/notes.txt" — single-file review (`--only` mode)
- "review this file", "annotate this file", "review file with revdiff"
- "open this review in revdiff", "show review in revdiff", "review in revdiff" — open an in-session review (preload mode)

## Answering Questions

Expand All @@ -37,6 +38,10 @@ ${CLAUDE_SKILL_DIR}/scripts/read-latest-history.sh

The script resolves the history dir from `$REVDIFF_HISTORY_DIR` (default `~/.config/revdiff/history`), finds the repo subdir via VCS root basename (jj/git/hg), and prints the newest `.md` file found. Each history file contains a header (path, refs, and — when available — a git commit hash), the annotations in `## file:line (type)` format, and the raw git diff for annotated files. The `commit:` line and diff block are captured from git only; in hg/jj repos the diff block will be empty and no commit hash is recorded. See `references/usage.md` "Review History" section for directory layout, stdin/only handling, and override options.

## Opening an In-Session Review

When the user asks to open an in-session review in revdiff (the conversation already contains review comments produced earlier in the session), write those comments to a temp file (e.g. `/tmp/revdiff-review-XXXXXX.md`) using the format documented in `references/usage.md` ("Output Format" section), then run the normal launcher flow (Step 1 ref detection, Step 2 invocation) with `--annotations=<temp-path>` appended. Step 3 onward handles the curated annotations as usual.

## How It Works

1. Launch revdiff in a terminal overlay (tmux popup, Zellij floating pane, kitty overlay, wezterm/Kaku split-pane, cmux split, ghostty split+zoom, iTerm2 split pane, or Emacs vterm frame)
Expand Down
4 changes: 4 additions & 0 deletions .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ Comment body lines starting with `## ` (the record-header form) are prefixed wit

Use `--output` / `-o` flag to write annotations to a file instead of stdout.

## Preloading Annotations

Use `--annotations=PATH` to preload the annotation store from a markdown file in the same `-o` format. The format is bidirectional: any file written by `-o` can be read back via `--annotations` for round-trip workflows — review, quit, edit the file externally, relaunch, and continue from the preloaded state.

## Review History

When you quit with annotations (`q`), revdiff automatically saves a copy of the review session to `~/.config/revdiff/history/<repo-name>/<timestamp>.md`. This is a safety net — if annotations are lost (process crash, agent fails to capture stdout), the history file preserves them.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ Positional arguments support several forms:
| `-X`, `--exclude` | Exclude files matching prefix, may be repeated, env: `REVDIFF_EXCLUDE` (comma-separated) | |
| `-F`, `--only` | Show only matching files by exact path or suffix, may be repeated (e.g. `--only=model.go`) | |
| `-o`, `--output` | Write annotations to file instead of stdout, env: `REVDIFF_OUTPUT` | |
| `--annotations` | Preload annotations from a markdown file in `-o` format | |
| `--history-dir` | Directory for review history auto-saves, env: `REVDIFF_HISTORY_DIR` | `~/.config/revdiff/history/` |
| `--config` | Path to config file, env: `REVDIFF_CONFIG` | `~/.config/revdiff/config` |
| `--keys` | Path to keybindings file, env: `REVDIFF_KEYS` | `~/.config/revdiff/keybindings` |
Expand Down Expand Up @@ -484,8 +485,15 @@ printf '# Plan\n\nShip it\n' | revdiff --stdin --stdin-name plan.md

# capture annotations from generated output
some-command | revdiff --stdin --output /tmp/annotations.txt

# round-trip: capture, edit externally, reload
revdiff -o review.md HEAD~1
$EDITOR review.md
revdiff --annotations=review.md HEAD~1
```

`--annotations` reads the same markdown format that `-o` writes (see [Output Format](#output-format) below), so any file revdiff produces can be loaded back. You can also hand-author or generate that file from any other source — each record is `## path/to/file.go:LINE (+)` followed by the comment body — then step through the comments inline against the actual diff, edit or delete them in the TUI, and quit with `q` to write the final set to stdout (or to `-o`).

### All-Files Mode

Use `--all-files` (or `-A`) to browse all tracked files in a project, not just files with changes. This turns revdiff into a general-purpose code annotation tool. All files are shown in context-only mode (no `+`/`-` markers) with full annotation and syntax highlighting support.
Expand Down Expand Up @@ -525,7 +533,7 @@ Two scenarios trigger this mode:

Use `--stdin` to review arbitrary piped or redirected text as a single synthetic file. All lines are shown as context, so the normal single-file review flow still works: annotations, file-level notes, search, wrap, collapsed mode, and structured output.

`--stdin` is explicit and mutually exclusive with refs, `--staged`, `--only`, `--all-files`, `--include`, and `--exclude`. stdin mode requires piped or redirected input; plain terminal stdin is rejected to avoid accidentally launching an empty scratch buffer.
`--stdin` is explicit and mutually exclusive with refs, `--staged`, `--only`, `--all-files`, `--include`, `--exclude`, and `--annotations`. stdin mode requires piped or redirected input; plain terminal stdin is rejected to avoid accidentally launching an empty scratch buffer.

Use `--stdin-name` to control the synthetic filename. This gives annotation output a stable key and enables filename-based syntax highlighting or markdown TOC activation:

Expand Down
151 changes: 151 additions & 0 deletions app/annotation/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package annotation

import (
"bufio"
"errors"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)

// headerRe matches the four record-header shapes emitted by Store.FormatOutput:
//
// ## path (file-level)
// ## path:N (T)
// ## path:N-M (T)
//
// where T is one of "+", "-", or " " (literal space).
var headerRe = regexp.MustCompile(`^## (.+?)(?::(\d+)(?:-(\d+))?)? \((file-level|\+|-| )\)$`)

// Parse reads the markdown produced by Store.FormatOutput and returns the
// recovered annotations in source order. Duplicates (same file/line/type) are
// returned as separate records; callers feed them through Store.Add to apply
// last-write-wins semantics.
//
// A line beginning with "## " that does NOT match the header grammar is folded
// into the body of the current record so hand-authored or LLM-generated bodies
// can mention "## something" without escaping. If such a line appears before
// any record header, it is reported as an error.
func Parse(r io.Reader) ([]Annotation, error) {
p := parser{scanner: bufio.NewScanner(r)}
p.scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
return p.parse()
}

type parser struct {
scanner *bufio.Scanner

out []Annotation
current *Annotation
body []string
seenHeader bool
nonBlankSeen bool
}

func (p *parser) parse() ([]Annotation, error) {
for p.scanner.Scan() {
line := p.scanner.Text()
if strings.HasPrefix(line, "## ") {
ann, err := p.parseHeader(line)
if err != nil {
// non-grammar "## " line inside a record: treat as body content
// (post-strip of any leading-space escape) so authored bodies
// can mention "## foo" without escaping. Before the first
// header, propagate the error.
if !p.seenHeader {
return nil, err
}
p.appendBody(line)
continue
}
p.flush()
p.seenHeader = true
p.current = &ann
continue
}

if !p.seenHeader {
if strings.TrimSpace(line) == "" {
continue
}
p.nonBlankSeen = true
break
}

p.appendBody(line)
}
if err := p.scanner.Err(); err != nil {
return nil, fmt.Errorf("scan annotations: %w", err)
}

if !p.seenHeader && p.nonBlankSeen {
return nil, errors.New("annotation input has content before any header")
}

p.flush()
return p.out, nil
}

// appendBody adds a body line, stripping the inverse of escapeHeaderLines:
// exactly one leading space when the line's first non-space content begins
// with "## ".
func (p *parser) appendBody(line string) {
if strings.HasPrefix(line, " ") && strings.HasPrefix(strings.TrimLeft(line, " "), "## ") {
line = line[1:]
}
p.body = append(p.body, line)
}

func (p *parser) flush() {
if p.current == nil {
return
}
// FormatOutput always emits a trailing newline after the body. Strip
// exactly one trailing empty line that came from the format separator.
if n := len(p.body); n > 0 && p.body[n-1] == "" {
p.body = p.body[:n-1]
}
p.current.Comment = strings.Join(p.body, "\n")
p.out = append(p.out, *p.current)
p.current = nil
p.body = nil
}

// parseHeader parses a single "## ..." header line into an Annotation.
// returns an error if the line does not match the expected grammar.
func (p *parser) parseHeader(line string) (Annotation, error) {
m := headerRe.FindStringSubmatch(line)
if m == nil {
return Annotation{}, fmt.Errorf("malformed annotation header: %q", line)
}
ann := Annotation{File: m[1]}
if m[4] == "file-level" {
// file-level headers are emitted as "## path (file-level)" with no
// numeric suffix on the path. if the regex consumed a `:N`/`:N-M`
// tail into the optional line group, the path itself ended in
// `:N`/`:N-M` — restore it so paths that look numeric round-trip.
if m[2] != "" {
ann.File += ":" + m[2]
if m[3] != "" {
ann.File += "-" + m[3]
}
}
return ann, nil
}
ann.Type = m[4]
n, err := strconv.Atoi(m[2])
if err != nil {
return Annotation{}, fmt.Errorf("malformed annotation header line number: %q", line)
}
ann.Line = n
if m[3] != "" {
end, err := strconv.Atoi(m[3])
if err != nil {
return Annotation{}, fmt.Errorf("malformed annotation header end line: %q", line)
}
ann.EndLine = end
}
return ann, nil
}
Loading