Review a diff in Neovim, leave typed notes on it, and hand the whole review to a coding agent as one message.
One syntax-highlighted diff of everything a branch changed — unified or side by side — with vim-native navigation, a file tree, reviewed-file collapsing, and a batch submit at the end.
┌─ tree ──────────────┐┌─ branch vs origin/master · ✓2/7 · +9 -4 ──────────────────┐
│ ▾ apps 1/4 ││ ○ ▾ apps/api/src/main.ts +12 -3 [3 notes]│
│ ▾ api/src 1/2 ││ @@ -19,6 +19,8 @@ function boot() │
│ ▾ routes 0/1 ││ 19 │ const app = express() │
│ ○ users.ts ││ ▌20 │ -const cfg = load() │
│ ● main.ts 3 ││ ▌21 │ +const cfg = loadConfig() │
│ ▾ web/src 0/2 ││ ▌ │ 🐞 why the rename? no callers were updated │
│ ✓ index.ts ││ 22 │ app.listen(cfg.port) │
│ ▾ packages/… 0/1 ││ │
│ ○ types.ts ││ ✓ ▸ apps/api/src/routes.ts +4 -0 │
│ ○ README.md ││ │
│ 2/7 reviewed ││ ○ ▾ apps/web/src/index.ts +2 -1 │
└─────────────────────┘└───────────────────────────────────────────────────────────┘
Everything is drawn natively in Neovim — no terminal window, no browser, no web view.
{
"felipeva/codereview-annotator.nvim",
cmd = "CodeReview",
opts = {},
}That is the whole setup. Six things get you through a review:
| Do this | What happens |
|---|---|
:CodeReview |
Opens the diff of everything your branch changed |
ab |
Annotates the line under the cursor as a bug (af fix, as suggestion, an nitpick, ai issue) |
R |
Marks this file reviewed — it collapses out of the way |
]F |
Jumps to the next file you have not reviewed |
Q |
Opens the queue, listing everything you have written so far |
<C-s> |
Submits the batch |
Nothing else is required. With no adapters wired the review still renders,
annotates and queues; the submitted batch lands in the + register instead of an agent,
and stays queued because nothing consumed it.
Requirements — Neovim 0.12+ and git. Treesitter parsers are optional: files
without one fall back to flat diff colours.
:CodeReview opens the branch review. Pass a scope to open something else, or press gs
inside the view to cycle between them in place.
| Command | What it shows |
|---|---|
:CodeReview |
Everything the branch changed against its base |
:CodeReview staged |
The index |
:CodeReview unstaged |
Working tree vs index |
:CodeReview worktree |
Everything uncommitted |
:CodeReview since-batch |
What changed since the last batch went out |
:CodeReview HEAD~3 |
Any git revspec |
:CodeReview main...feature |
Any range |
Files are marked reviewed against their git blob, so a mark stops meaning anything once the file changes underneath it.
since-batch is the one to reach for while an agent is working. Submitting records a
snapshot of the working tree, and this scope diffs against it — so what you get is the
agent's response to your review, without the work you already had in flight when you sent
it. It behaves like every other scope in every other respect: highlighted, navigable,
collapsible, annotatable, and drawn in both layouts. gs reaches it once something has
been dispatched from the repository, and never before that; asking for it by name with
nothing dispatched says so in a sentence.
layout = "unified" (the default) stacks deleted and added lines in one column.
layout = "split" draws the same diff as two panes — the before-image on the left, the
after-image on the right — with corresponding code on the same screen row.
opts = { layout = "split" } -- "unified" (default) or "split"; validated at setup()┌─ tree ───────────┐┌─ Before · origin/master ────┐┌─ branch · ✓2/7 ──────────────┐
│ ▾ apps 1/4 ││ apps/api/src/main.ts ││ ● ▾ apps/api/src/main.ts +12 │
│ ▾ api/src 1/2 ││ @@ -19,6 @@ ││ @@ +19,8 @@ function boot() │
│ ● main.ts 3 ││ 19 │ const app = express()││ 19 │ const app = express() │
│ ▾ web/src 0/2 ││ ▌20 │ -const cfg = load() ││ ▌20 │ +const cfg = loadCfg() │
│ ✓ index.ts ││ ││ ▌ │ 🐞 why the rename? │
│ ○ README.md ││ ││ ▌21 │ +cfg.validate() │
│ 2/7 reviewed ││ 21 │ app.listen(cfg.port) ││ 22 │ app.listen(cfg.port) │
└──────────────────┘└─────────────────────────────┘└──────────────────────────────┘
The blank rows on the left are filler: the addition has no counterpart in the before-image, so the left pane holds its place rather than letting the two drift apart. Annotating from a filler row targets the whole file.
Both panes are syntax-highlighted, scroll together, and behave identically: collapsing, reviewed marks, capture and navigation all work the same way, and the pane you are in decides what is captured — a deleted line on the left, the post-image line on the right.
gl switches layouts without losing your place, from either pane or the file tree, so
you can reach for side-by-side on the one reformatted file without editing your config. The
choice lasts the rest of the session and resets when Neovim exits.
The details that occasionally matter
Buffer rows mean nothing across layouts — the same line of the same hunk sits at a
different row in each. What gl carries across is the anchor (the same line, of the
same hunk, of the same file), and the row carrying it is found again afterwards. So the
cursor lands in the pane its line belongs to, switching back is unambiguous, and a cursor
on filler falls back to that file's header. The landing line is centred, because preserving
an exact scroll offset across a structural move would be preserving something meaningless.
Chrome is split too: a hunk header shows its pre-image range on the left and its post-image range plus git's section heading on the right, and a renamed file shows its old path on the left and its new path on the right — on the winbar as well as in the buffer, so the left pane's sticky header names the pre-image path beside the revision it is showing.
Horizontal scrolling is not synchronised, because doing so would mean changing
scrollopt, a global option this plugin does not own.
One behavioural difference, and only one: a visual range cannot span both images, because the two runs live in different windows and cannot be in one selection. Pure-addition and pure-deletion ranges work, and annotating the hunk header captures both images inlined — so what a split range cannot express is still one keystroke away.
A layout is a rendering choice and nothing else, so which one was on screen never reaches the receiving agent (ADR-0002).
Rename one identifier in an eighty-column line and a plain diff shows you two eighty-column lines, one red and one green, with nothing pointing at the four characters that differ. So the spans that actually differ are emphasised inside the pair, and the rest of the line keeps its ordinary red or green — you read the change, not the line.
▌20 │ -const cfg = load()
▌20 │ +const cfg = loadConfig()
▔▔▔▔▔▔ emphasised
On by default, in both layouts, and in both panes on the same row.
opts = { spans = false } -- true by default; validated at setup()Granularity is characters, not words, so an edit inside an identifier is pointed at precisely and a re-indentation is visible rather than mysterious. Where two lines share so little that emphasising the change would emphasise nearly all of both, they are left plainly coloured: that is a replacement, not an edit.
The details that occasionally matter
Which lines pair. The i-th deletion of a contiguous run pairs with the i-th addition of
the run that follows it — the pairing the split layout already uses to put a deletion and
its replacement on one row. Using it in both layouts is why the same characters are
emphasised either side of a gl. Where the runs are unequal the surplus lines carry
nothing, and neither does a pure addition, a pure deletion, or a file that exists on only
one side: there is nothing to compare them against.
When it says nothing. Above 60% of the longer line inside spans, the pair is left alone. The figure was read off real diffs rather than derived — at 70% a quarter of the unrelated pairs inside a long run were still being emphasised, and every genuine one-line edit above 60% turned out to be a replacement wearing an edit's clothes. A re-indentation is nowhere near it: every reformatted pair measured came in under 10%.
When the work happens. At parse time, once per git read — never during a repaint. On a
12,000-line diff the spans cost about as much again as a whole repaint, and a repaint runs
on every resize, expansion, reviewed toggle and scope change. make perf reports the figure
on its own line so a change that moves the work back into the render is visible.
Two highlight groups, CodeReviewAddSpan and CodeReviewDelSpan, both taking
DiffText's background and nothing else. A foreground there would sit beneath the
treesitter replay: it would lose wherever a parser had painted and win wherever one had not,
which is emphasis that changes colour depending on which languages you have installed.
The emphasis is a rendering refinement and nothing else. What you capture, what reaches the queue and what the payload says are untouched (ADR-0002).
The tree collapses single-child directory chains (apps/api/src, not three nested rows),
sorts directories before files, and carries a reviewed tally on every directory — so you
can see which packages are done without opening them. It follows the diff cursor, and
<Tab> into it lands on the file you were reading.
gp dismisses and summons it; panel.enabled decides whether a review opens with one.
Collapsed directories belong to the review rather than to the tree, so they are exactly as
you left them when it comes back.
A review can hold three windows at once — the before pane, the after pane and the tree — and the pane without focus is muted: its colours pulled toward the background, so where you are is on screen instead of something you have to press a key to find out.
Both panes go on lighting the row their cursor is on. The muted one lights a counterpart
row — the same row, in a group of its own, blended more gently than the muting so it is
bright enough to find and never competes with the pane you are in. The panes are cursorbound,
so that row is the one opposite the line you are reading, and on a pure addition it is the
filler: a lit blank row saying nothing was here before, which is the fact you wanted and
the one you could not otherwise get without counting rows. counterpart = { enabled = false }
goes back to a lit row in the focused pane only.
The file tree is never muted. It is the map of the review — the paths, the icons, the
per-directory tallies and the +N -M counts — so it draws in your colorscheme's own colours
whichever window you are in, with the row for the file you are reading lit. Move into it and
the panes mute, both of them in the split layout and the one of them in the unified layout,
so you can see that your keys now act on the tree.
The colours come from your own colorscheme, blended: nothing here has a palette of its own, and a group the plugin cannot know about is left bright rather than guessed at, so an unfamiliar theme degrades to less muted and never to wrong. A float — the composer, the queue float, the last-batch float — changes nothing: the bright window is the review window you were last in, not the window with the cursor in it.
muted = { enabled = false } turns it off whole; strength is one number from 0 to 1
saying how far toward the background a colour goes.
Every other file in the review is faded: the colours of its rows pulled toward the background, so the file you are reading has a boundary and the file you just left stops competing with it. Cross into another file and the fade follows the cursor. Move from one hunk to the next inside one file and nothing changes at all — the unit is the file.
A faded file keeps its header row bright, with its path, its +N -M and its note count, so
the review reads as bright headers over quiet bodies with one file at full strength. Its
hunk headers fade with its body.
The colours are the muting's, blended from your own colorscheme at a strength of its own, and a faded file keeps its syntax structure rather than flattening to one tone: what changes is which group each mark carries, never the order they are drawn in. A group your theme gives no colour of its own is left bright, exactly as it is in a muted window.
faded = { enabled = false } turns it off whole; strength is its own number, and it is
gentler than the muting's by default because this covers every file but one.
A file's header row scrolls off the top as soon as you read past the first screenful of it. The sticky header keeps it:
○ ▾ src/routes.lua +12 -3 [2 notes] branch vs master · ✓2/7 · +40 -9 · ●2 · → janus
The same icon, chevron, path, +N -M and note count the in-buffer header carries, with the
review summary right-aligned beside it. It names the file the cursor is in — not the one
at the top of the window — so it is the file an annotation would attach to, which is what
you are reaching for when you annotate twenty lines into a hunk. It works with the tree
dismissed, and with a review opened without one.
The summary counts in glyphs rather than words — ✓2/7 reviewed, ●2 notes, ↺2 untouched
— so that three numbers side by side cannot be read as one another. Every one comes from the
icons table, so you can change any of them, and every one is plain Unicode: nothing here
needs a Nerd Font. The bar does not name the review itself; a bar inside a review does not
need to.
It is coloured. Added counts green and removed counts red, the note count in the colour notes
carry everywhere else, the delivery target accented, the separators quieter than the
facts between them, and the path the brightest thing on the left — which is the thing you
scrolled there to keep. The groups are the plugin's own (CodeReviewBarIcon,
CodeReviewBarSep, CodeReviewBarTarget, CodeReviewBarRev, beside the stat and note-count
groups the diff already uses), and every one is a default link into your colorscheme, so a
theme you have never used supplies the colours and changing theme mid-review follows.
In the split layout each pane names its own side, so a rename reads correctly on the side
holding the old name; the unified layout spells it old → new, exactly as the file header
does. On a pane too narrow for both halves the summary gives way first — starting with what
the file beside it now says twice — and the path keeps its tail. The before pane's bar names
the base revision, and never gives it up for the path: half a revision is worse than none of
a path the other pane is naming anyway.
The bar is chrome of the review window it sits on, so it mutes with that window: on the pane without focus the sticky header recedes with everything else in it, colours and all.
A type is not decoration — it changes what the receiving agent is told to do with that group of notes.
| Type | Key | Group directive in the payload |
|---|---|---|
| bug | ab |
diagnose and fix these |
| fix | af |
apply these changes |
| suggestion | as |
evaluate; apply if sound |
| nitpick | an |
low priority — batch these together |
| issue | ai |
do NOT fix — summarize these for tracking |
aa opens a picker instead. Its last entry is no type — a remark worth reading with
no instruction attached. It behaves like any other entry and its group renders last, under
a bare ## Untyped (n) heading. Declining a type is not dismissing: escape still abandons
the annotation entirely.
Replacing the type set
opts.types replaces the whole set. Only name and key are required — everything else
is derived, so adding a type costs two fields:
types = {
{ name = "bug", key = "b", directive = "diagnose and fix these" },
{ name = "nitpick", key = "n", directive = "ignore unless trivial" },
{ name = "question", key = "q" },
}| Field | Default |
|---|---|
name |
required — what annotate() takes and what an entry stores |
key |
required — pressed after the a prefix, so q binds aq |
label |
the name, title-cased and pluralised: question → Questions |
icon |
icons.annotated |
hl |
CodeReview<Name>, auto-linked to DiagnosticInfo so it has colour |
directive |
none — the payload heading is then just ## Questions (3) |
Pluralisation is naive (+s, unless the name already ends in one), so a name English
declines irregularly wants an explicit label. Order is the order groups appear in the
payload, most actionable first.
A list that cannot work is rejected at setup() naming the entry that caused it — a
missing name or key, a duplicate of either, a key of a (which would shadow the aa
picker), or a field of the wrong type. Start from the shipped set with
require("codereview.types").defaults.
| Cursor is on | What gets annotated |
|---|---|
| A diff line | That line |
| A visual selection | Those lines |
| A hunk header | The whole hunk |
| A file header | The whole file |
| A filler row (split layout) | The whole file |
Plus a bare note with no file behind it at all — see below.
Capture does not need a review. :CodeReviewAnnotate bug queues a note about the file you
are looking at; with no type it offers the same picker aa does.
require("codereview").annotate("bug") -- the selection, or the whole file
require("codereview").annotate() -- pick the type from a menu, or decline oneThat is the entry point to bind a key to — nothing needs to reach into the plugin's internal modules to capture. Bind it in both modes and the selection decides the scope:
vim.keymap.set({ "n", "x" }, "<leader>ab", function()
require("codereview").annotate("bug")
end, { desc = "Annotate as a bug" })| Where you are | What gets captured |
|---|---|
| Normal mode | The whole file |
| A visual selection | Exactly those lines |
:'<,'>CodeReviewAnnotate bug |
Exactly those lines |
:12,20CodeReviewAnnotate bug |
Lines 12 to 20 |
| A file outside any checkout | The file, by absolute path |
| A buffer with nothing on disk | A bare note, with no path at all |
The last two are not second-class: they queue, submit and survive a restart like anything else — a scratch buffer is a fine place to leave a thought for the batch. What you get is an ordinary annotation, sharing the queue, the payload grouping, the batch and the staleness rules with anything captured during a review. The buffer itself is never touched.
Diagnostics ride along. Errors and warnings overlapping what you captured are attached to the note, so they stop being retyped by hand. Hints and info are left out — they are rarely why you are annotating, and they bury the diagnostic that is.
why is this branch unreachable?
Diagnostics:
- ERROR L12 undefined global `foo` (lua_ls)
- WARN L14 unused local `bar` (lua_ls)
A thought you want acted on should not have to wait for a batch you have not finished assembling. The same entry point sends one annotation on its own instead of queueing it:
require("codereview").annotate("bug", nil, { immediate = true })
require("codereview").annotate(nil, nil, { immediate = true }) -- or pick the typeDelivery is a property of the call, not a different door: the same paths, the same blob,
the same diagnostics, the same drafts and @ references, and the same picker. What arrives
is an ordinary review payload that happens to hold one annotation
(ADR-0004).
You are asked where it goes before the composer opens, so declining costs no typing. The queue is untouched — an annotation sent this way never joins it, and whatever is already queued is neither delivered nor cleared. If the send does not go through, the note is kept as a draft so annotating that file again offers it back.
Notes are written in a real buffer, not a prompt. Drafts are kept per target and survive
restarts, and @ references another file — pick_file supplies the picker.
Annotations accumulate in a queue that survives :qa and restarts. Q opens a float
listing the batch, where <CR> jumps to any entry — closing the float and centring the
line the annotation is about, expanding the file first if it was collapsed. The queue is
therefore a way of navigating a review, not only of auditing it before you send.
Because the queue is shared with the capture path and the float opens with or without a review, some of what it lists has nowhere to jump to: a bare note is about no file, there may be no review open, or the file may be outside the current scope. Each says which.
gy — in the diff, in the float, or as :CodeReviewCopy from anywhere — puts the payload
in the + register without submitting it. It is the same text send would have been
handed, target and all, and it costs the batch nothing: the queue is untouched, so reading
what an agent will be told is not a decision to send it.
<C-s> submits with no questions asked. <C-a> — in the diff and in the queue float —
opens the composer first, and submits when you submit the composer. What you write is the
preamble: the prose above the batch, addressed to the agent, that is not an annotation.
It is where what the batch is about as a whole goes — which part matters most, what to
ignore, how the pieces relate — instead of an untyped annotation in the middle of the
findings.
It is rendered above the header, so it is read before them, and it is never queued. A preamble is written at the moment the batch goes and forgotten afterwards: the queue holds annotations and nothing else.
Abandon the composer and nothing is sent. The queue is whole, nothing is archived, and what
you wrote is kept as a draft, so the next <C-a> offers it back rather than making you
write it twice. That draft is kept per repository rather than per file, because a preamble
is about a batch and not about a line of code.
An empty composer still submits and renders nothing at all, leaving the payload exactly
what <C-s> would have sent. gy copies no preamble either, because copying is not
submitting, and an annotation sent on its own carries none: a
batch of one has no batch to cover.
A preamble that went is part of the record. It is kept with its batch and read back with it — a float listing the findings and not the covering note would be describing a message nobody received. Shown there and never edited, for the reason that float refuses everything else.
A submit clears the queue, which is the moment "what did I actually ask for?" stops having
an answer anywhere but the agent's transcript. :CodeReviewLastBatch gives it one: the
annotations of the batch that went last, grouped by type exactly as the queue float and the
payload group them, under the preamble it went out under
where there was one, with the target it went to and when it went in the frame.
gb opens that same float from inside a review, in the diff and in the tree, so which
window you are in never decides whether the key exists. The command needs no review open —
a batch is not a window, and what you asked for is worth checking from anywhere. A bare note
is listed like anything else, even though it was kept in a different store than the
annotations with a file behind them.
It is read-only, and that is a statement about the record rather than a feature left out. An archived entry says something happened; a surface that let you drop, edit or resubmit one would be claiming the plugin can revise what an agent already received. To raise a point again, annotate again — that is an ordinary capture, and it joins the next batch.
A dispatched batch does not leave the review view. Its entries keep the anchors they were bound to and are drawn dimmed beneath the code they were about, projected exactly as live annotations are. A reviewer who keeps going while the agent works is otherwise looking at a diff with no memory of what it already sent, and reports the same finding twice.
They draw in every scope, not only since-batch — knowing what has already been said is
worth as much wherever you are. An anchor carrying both a queued and an archived entry draws
both, the queued one first: what is still to send outranks what has already gone. x there
drops the queued one and never the archived one; nothing on the diff can revise a batch that
already went, for the same reason the last-batch float is read-only.
Their two highlight groups are their own — CodeReviewArchived for the marker and
CodeReviewArchivedNote for the prose — and are default = true links like everything else
here, so a colorscheme can say how dim "already sent" looks. archived = false turns them
off wholesale, and the diff then renders exactly as it did before the archive existed.
gA reaches that switch from inside a review, in the diff and in the tree. On a fresh
review over code you already annotated they are noise — you are looking for what is wrong
now, and the diff is covered in what was already said — so one key takes them off, and the
same key brings them back. It repaints at once and runs in both directions: with
archived = false configured, gA turns them on.
It overrides the configured value instead of writing it, so your setup() call goes on
saying what you wrote. The override holds for every review you open afterwards — you press
the key once, not once per review — and it dies with the Neovim process, so a display
preference never becomes durable state you forgot you set. Off is off entirely, exactly as
the flag is: the untouched tally leaves the winbar with the entries, and no git is spent
judging them.
Each entry of the last batch says whether its file has changed since that batch was
dispatched — file changed or file unchanged beside the note — and the winbar tallies the
ones that have not:
branch vs master · ✓2/7 · +40 -12 · ●1 · ↺2 · → janus
↺2 is the answer to did it ignore something, and it is exact on the case that
matters: a file the agent never opened. The comparison is per file, against the snapshot
the batch went out with — the same commit since-batch diffs against, so a file the tally
calls touched is exactly a file that scope shows you.
The word is touched, not addressed. The plugin knows the file moved. It does not know that anyone read your note, agreed with it, or acted on it, and it will not imply otherwise. It is also not staleness: that is the same kind of comparison against a different blob, it is about entries still in the queue, and it means this note may now be wrong rather than something happened here. The two are never merged, and an archived entry never shows a stale flag.
Three things are left unjudged rather than guessed at — a file the current scope does not cover, a file that was untracked when the batch went (a snapshot does not record those), and a bare note, which is about no file at all. A file deleted since the dispatch counts as touched. Nothing judged means no segment at all rather than a zero to misread.
That the scope decides who is judged is why the tally reads 0 untouched inside
since-batch: that scope shows you exactly the files that moved, so every entry it covers is
touched by definition, and the ones you are looking for are the ones it is not showing.
branch and worktree are where the number earns its keep.
CodeReviewTouched and CodeReviewUntouched are the groups, and archived = false — or
gA while you are reviewing — turns the tally off along with everything else.
Grouped by type, in the configured order, most actionable first, with anything untyped
last. A diff is inlined where an @ref cannot carry the change. A preamble, where one was
written, sits above the header with a blank line under it; with none the payload starts at
the header.
the auth rewrite is the part to read — the route moves are mechanical
Code review — 4 annotations on branch vs origin/master (8 files, 6 reviewed)
## Bugs (2) — diagnose and fix these
### 1. @apps/api/src/main.ts#L20-21
why the rename? no callers were updated
### 2. apps/api/src/routes.ts:14 (deleted)
```diff
-router.use(legacyAuth)
```
was this dropped on purpose?
## Nitpicks (1) — low priority — batch these together
...
## Untyped (1)
### 4. @apps/api/src/db.ts#L8
worth a look before we ship this| Key | Action |
|---|---|
ab af as an ai |
Annotate as bug / fix / suggestion / nitpick / issue |
aa |
Annotate, picking the type from a menu or declining one |
x |
Drop the annotation under the cursor |
R |
Toggle reviewed on this file (collapses it) |
za |
Toggle expansion without marking reviewed |
gs |
Cycle scope, re-rendering in place — since-batch joins once a batch has gone |
gr |
Re-read the diff from git |
gp |
Show or hide the file tree |
gl |
Switch between the unified and split layouts |
gb |
Read the last dispatched batch back |
gA |
Show or hide archived entries, for the rest of the session |
<CR> |
Open the real file here, in a new tab |
gd |
Read this file in your own diff tool — only bound with open_diff wired |
Q |
Review the queue |
gy |
Copy the batch to the + register, without submitting it |
<C-t> |
Choose the delivery target |
<C-s> |
Submit the batch |
<C-a> |
Submit the batch under a preamble |
q |
Close |
Annotation keys are prefixed with a rather than bound bare, because bare b, f, n
and s would shadow back-word, find-char, next-search and (if you use it) flash.nvim
inside the buffer. a is append, which is dead in a nomodifiable buffer, so the prefix
costs a keystroke and no motion.
| Key | Action |
|---|---|
]f [f |
Next / previous file |
]F [F |
Next / previous unreviewed file — wraps |
]h [h |
Next / previous hunk |
]a [a |
Next / previous annotation |
<C-p> |
Jump to a file by name, from a list |
<Tab> |
Move between the diff and the tree |
]F is the one that matters once a review is underway: the point of marking files reviewed
is to stop looking at them, so the useful motion is "the next thing I have not done", not
"the next file", which walks back through everything already finished.
| Key | Action |
|---|---|
<CR> / o |
Open the file, or fold the directory |
gd |
Read the file under the cursor in your own diff tool — only bound with open_diff wired |
h / zc |
Collapse the directory — on a file, collapses its parent |
l / zo |
Expand the directory |
za |
Toggle the directory |
zM / zR |
Collapse / expand every directory |
]f [f |
Next / previous file, skipping directory rows |
R |
Toggle reviewed — on a directory, the whole subtree |
<C-p> |
Jump to a file by name |
<Tab> |
Back to the diff |
gp |
Dismiss the tree, landing back in the diff |
gl |
Switch between the unified and split layouts |
gb |
Read the last dispatched batch back |
gA |
Show or hide archived entries, for the rest of the session |
q |
Close |
Jumping to a file that was collapsed because it is reviewed expands it: you asked to look at it.
Each annotation is a run of rows carrying a bar in its type's colour — its location, the
code it inlines, and its note, kept as you wrote it and wrapped to the float. The bar runs
through blank lines inside a note and stops between annotations, so you can always see
where one ends. Every row belongs to the annotation it is drawn under, so x drops what is
under the cursor and not what a heading further up happened to say.
| Key | Action |
|---|---|
<CR> |
Jump to the annotation under the cursor |
x |
Drop it |
gy |
Copy the batch to the + register, without submitting it |
<C-t> |
Choose the delivery target |
<C-s> |
Submit the batch |
<C-a> |
Submit the batch under a preamble |
q / <Esc> |
Close, keeping the queue |
gy leaves the float open, because it takes nothing out of the queue — everything it is
listing is still there.
gb, or :CodeReviewLastBatch from anywhere, lists an annotation the same way — the
reserved gutter, the bar in its type's colour, the code it inlined and its note — so the two
read as one family. A preamble is drawn above them all, as
prose rather than as an entry: no gutter and no bar, because it is about the batch and not
about a place in the code. What is missing is every key that would change something.
| Key | Action |
|---|---|
q / <Esc> |
Close |
x <C-s> |
Bound only to say why they do nothing here |
Those two are bound rather than left off so that the queue float's muscle memory gets a
sentence instead of E21. Nothing else is: the batch has gone, and there is nothing left
to route, drop or send.
opts = {
context = 3, -- git diff -U
untracked = true, -- show untracked files in branch/worktree scopes
syntax = true, -- treesitter highlighting
max_syntax_bytes = 256 * 1024, -- skip syntax above this size
layout = "unified", -- "unified" or "split"; validated at setup
spans = true, -- emphasise what changed inside a changed line
archived = true, -- draw already-dispatched entries on the diff, dimmed,
-- and tally the untouched ones on the winbar; `gA`
-- overrides this for the rest of the session
muted = { enabled = true, -- mute the pane that does not have focus; never the tree
strength = 0.5 }, -- how far its colours are pulled toward the background
faded = { enabled = true, -- fade every file except the one the cursor is in
strength = 0.35 }, -- its own number: this covers every file but one
counterpart = { enabled = true, -- light the row opposite the cursor in a muted pane
strength = 0.25 }, -- gentler than the muting, so the row stays findable
panel = { enabled = true, width = 34, position = "left" },
icons = { reviewed = "✓", annotated = "●", unreviewed = "○",
collapsed = "▸", expanded = "▾", change_bar = "▌",
untouched = "↺" }, -- plain Unicode throughout: no Nerd Font anywhere
types = nil, -- defaults to the five above; see Typed annotations
}Every highlight is a default = true link to a group your colorscheme already defines
(DiffAdd, Added, DiagnosticError, …), so overriding any CodeReview* group works
without the plugin fighting back. The two exceptions are CodeReviewAddSpan and
CodeReviewDelSpan, which take DiffText's background and set no foreground — still
default = true, so overriding them works the same way.
Three families of groups are computed rather than linked, and none is yours to set. A
muted pane draws through a twin of each group named CodeReviewMuted. plus the group
it blends — CodeReviewMuted.CodeReviewAdd, CodeReviewMuted.@keyword. A faded file's
rows are drawn in a twin named CodeReviewFaded. plus the same, at faded.strength instead
of muted.strength. The counterpart row a muted pane lights is one member only,
CodeReviewCounterpart.CursorLine, at counterpart.strength. Each holds that group's own
colours pulled that far toward the background, and each is written again on every colorscheme
change, so setting one yourself is overwritten. Set the strength, or the group the twin is
named for. A group your theme gives no colour of its own gets no twin in any of the three,
and is drawn at full brightness — muted, faded or lit, that is the same rule.
The plugin has no opinion about where a review goes, about which pickers you use, or about which diff tool you read a rewrite in. Five optional functions inject that — none are required.
| Adapter | What it supplies | Without it |
|---|---|---|
send |
Delivers the rendered batch | The payload goes to the + register |
pick_target |
Chooses a delivery target | No target; send decides what that means |
pick_file |
Picks a file for @ in the composer |
@ stays a literal @, and says so |
compose |
Collects note text | The composer the plugin ships |
open_diff |
Reads one file in your own diff tool | gd is not bound at all |
opts = {
send = function(payload, target) return true end,
pick_target = function(cb) cb({ short = "agent", cwd = "/path" }) end,
pick_file = function(cb) cb({ path = "src/main.lua", first = 12, last = 20 }) end,
compose = function(ctx, on_accept, label) on_accept(nil, "text") end,
open_diff = function(spec) end, -- spec: { path, before, after, line }
}You cannot annotate in whatever open_diff opens. Nothing there has an anchor map, so
it is a one-way trip: read the file closely, then come back to the review to say anything
about it. It is for the rewrite that Neovim's own diff mode reads better than a unified
diff does — a reformat, a re-indent, a file moved wholesale — not a second review surface.
The full contract for each
opts = {
-- Deliver the rendered batch. Report whether it was *handed off*, not whether it
-- arrived: return nothing or `true` for dispatched, `false` and a reason for not.
-- Raising counts as not dispatched too, with the error message as the reason.
-- A dispatch is the one thing that empties the queue, so a batch that did not go is
-- still there to retry, and an immediate send that did not go keeps its note as a
-- draft. A refusal is reported as an error. Without this the payload goes to the +
-- register, which is the default implementation of this same contract — it reports a
-- non-dispatch (a register is not a consumer), which is why an unwired host keeps its
-- queue, and it is a warning rather than an error because nothing is broken. `gy` puts
-- the payload in that register deliberately, whatever is wired here, so what an adapter
-- receives is readable without submitting to find out.
send = function(payload, target) return true end,
-- Choose a delivery target; call back with anything carrying `short` and `cwd`.
-- `cwd` matters: refs are re-resolved against it at submit time.
pick_target = function(cb) cb({ short = "agent", cwd = "/path" }) end,
-- Choose a file to reference from `@` inside the composer. The plugin ships no picker,
-- so without this `@` stays a literal `@` and says so. `first`/`last` are optional —
-- omit them and the reference is to the file rather than to a range in it.
pick_file = function(cb) cb({ path = "src/main.lua", first = 12, last = 20 }) end,
-- Collect note text. Without it you get the composer the plugin ships, which implements
-- this same contract — wiring one replaces that composer rather than upgrading a prompt.
-- `ctx` describes what is being annotated: `scope`, `label`, `rel_path`, `file_path`,
-- and `origin_win` — the window the annotation was started from. Focus goes back there
-- once `on_accept` runs; a composer the user can *cancel* never calls it, so that path
-- is the composer's to restore.
--
-- On an immediate send `ctx.routing` is also there — `{ label(), pick(on_done) }` for
-- the target *this note* will reach. Name it, and change it with `pick`. It is absent
-- for a note joining the queue, which the batch routes.
compose = function(ctx, on_accept, label) on_accept(nil, "text") end,
-- Read one file in the diff tool you already have — DiffviewOpen, :Gdiffsplit, your own
-- diffthis pair. The plugin ships none of that: it hands over the file and the two refs
-- its scope is between and stops caring. `path` is absolute and is the post-image path,
-- so for a rename the pre-image lives elsewhere in `before`. `after` is nil whenever the
-- post-image is the working tree — that is most scopes, and it is not an error: nil
-- means the file on disk. `line` is nil when the file was named from the file tree,
-- which knows a file and no position in it.
--
-- Bound to `gd` only while this is wired, in the diff and in the tree. A key that
-- silently did nothing would be worse than no key.
open_diff = function(spec)
local rev = spec.after and (spec.before .. ".." .. spec.after) or spec.before
vim.cmd(("DiffviewOpen %s -- %s"):format(rev, vim.fn.fnameescape(spec.path)))
end,
}ADR-0005 records why "dispatched" is the narrow promise — the adapter most hosts wire is asynchronous, and holding the queue until an agent confirmed would keep a sent batch queued for the length of that agent's work.
Wiring it to a Claude session over herdr
opts = {
send = function(payload, target)
require("util.claude").deliver({ scope = "none", prerendered = true }, target, payload)
end,
pick_target = function(cb) require("util.claude").pick_target(cb) end,
compose = function(ctx, on_accept, label) require("util.claude").compose(ctx, on_accept, label) end,
}scope = "none" and prerendered = true are both load-bearing, and pick_target's cwd
is what decides whether refs survive. docs/herdr.md explains why, and
spells out which queue is which.
Reviewed marks, the queue and the batches already dispatched are stored per repository
under stdpath("state")/codereview/, keyed by scope and diff base, and survive a restart.
Each entry records the git blob it was captured against. On reload:
- a reviewed mark whose blob moved is silently un-marked — the file changed, so you have not reviewed what is there now;
- an annotation whose blob moved is kept and flagged
⚠ stale, because the prose is still worth sending and only its line anchor is untrustworthy. A stale entry never travels as an@ref; its code is inlined instead.
Two things deliberately do not persist: the delivery target, which names a live
destination a restart would make dead, and the layout gl last chose, which is a
preference rather than review progress. The store is per repository and neither of those
is; both last for the session and no longer.
Annotations with no repository behind them
A bare note, or a file outside any checkout, has nowhere repository-shaped to live, so it goes to a single global store beside the others. Nothing ever reconciles that store against a diff, so nothing would ever clear it: entries older than seven days are dropped when it is read. That bounds its growth but not its staleness, which is the accepted cost of not making those annotations second-class.
What a dispatched batch leaves behind
A batch that is dispatched is kept rather than forgotten: the annotations as they went,
the preamble they went out under where there was one, where they went, when, and a
snapshot of the working tree at that moment — a commit
object minted with git stash create, which moves no ref, leaves the index alone and never
touches your files. On a clean tree there is nothing to record that HEAD does not already
say, and HEAD is what is stored.
A dispatch writes it and nothing else does. An adapter that declined, one that raised, the
+ register the default send copies to and the one gy copies to deliberately all leave
it exactly as they leave the queue — a payload sitting in a register is not something an
agent received. An immediate send is a batch of one and is kept as one. The most recent
twenty batches are kept per store, oldest dropped on write.
:CodeReviewLastBatch reads the newest one back. A batch holding both kinds of annotation
is written to both stores and rejoined by the moment it was dispatched, which is what stops
a bare note being the one thing missing from what you read back. Both halves carry the same
preamble, for the reason they carry the same stamp and the same target: it belongs to the
dispatch and not to either half of it. A record written before batches could carry one
lacks the field and reads back with none, exactly as one written before the archive existed
lacks the archive.
What staleness is judged against
Each kind is judged against whatever its blob was actually taken from. A review annotation
is judged against the diff on screen, and a file the current scope does not include is not
evidence that anything changed — so it is left alone. An annotation captured from a buffer
has no scope behind it and is judged against the file on disk, at any scope and with no
review open. That distinction matters in a staged review, where the diff shows the index
and a buffer capture holds the working tree: judging one by the other would flag a note
about a file nobody has touched.
| Where | What is in it |
|---|---|
:help codereview |
The full reference — every command, mapping, option and Lua API |
docs/design-notes.md |
Every non-obvious constraint that cost real debugging time |
docs/adr/ |
The decisions behind the architecture, and why |
CONTEXT.md |
The project's vocabulary — scope, pane, filler, dispatch, … |
CONTRIBUTING.md |
Setup, tests, the commit convention, PR shape |
make deps # clone plenary into .tests/
make test # the suite: one Neovim per spec file, ~5s
make lint # stylua --checkTests are plenary/busted specs under tests/codereview/, each building its own throwaway
git fixture. CI runs them on Neovim stable and nightly with plenary as the only dependency
— no nvim-treesitter and no compiler, because the fixtures are Lua and Markdown and those
parsers ship with Neovim. See tests/README.md for the layout, what is
deliberately not covered, and the traps worth knowing before changing a fixture.
Issues and pull requests are welcome. Start with CONTRIBUTING.md —
setup is make hooks && make deps && make all, and it takes about five seconds to know
whether the suite is green.
Open an issue first for anything with a design decision behind it; typo fixes and
obviously-correct one-liners can go straight to a PR. Participation is under the
Code of Conduct; security reports go through
SECURITY.md, not the issue tracker.
This plugin was written with Claude Code, and it is set up so anyone can keep working on it that way. Said plainly for two reasons: so you know what you are reading, and so you know agent-assisted contributions are welcome here rather than merely tolerated.
The repo carries what an agent needs to be useful in it rather than merely fast:
CLAUDE.md holds the workflow in imperative form, docs/agents/
records where issues live and how they are labelled, and
docs/design-notes.md exists because several obvious-looking
approaches here are wrong for reasons no amount of reading the code reveals. Point an agent
at CLAUDE.md and it will follow the same branch, commit and PR conventions a human
contributor does.
The bar is the same either way — the tests pass, the design notes were read, and you can answer questions about the change in review. There is no requirement to disclose tool use, and no penalty for it.
MIT © Felipe Valencia