- Diff browser in a dedicated tab: file tree, branches, commits, comments
- Typed comments (note, fix, question) attached to specific diff lines
- Export all comments as markdown with diff context, to the clipboard or a tmux pane
- Quick comments on any line of any buffer, with gutter signs
- Git actions without leaving the tab: stage, commit, amend, push, pull, checkout, branch
- Session persistence, so comments survive a restart
- Auto refresh while an agent writes files
- Neovim 0.10 or later (enforced in
plugin/review.lua) giton$PATH- tmux, optional, only for
:Review send,:Review qsand the "Copy & Send to tmux" exit option. Everything else works without it. - nvim-web-devicons, optional, file icons. Without it the icon column is blank.
- Tree-sitter parsers for the languages you review, optional, syntax highlighting inside the diff. Without a parser the diff still renders, just uncolored.
:Review is registered by plugin/review.lua, so the command exists as soon as the plugin loads and the defaults in config.lua are already in effect. setup() is still worth calling: it registers the plugin's highlight groups, loads existing quick comments and sets up their gutter signs, enables autosave for the review session and for quick comments, and creates the optional global keymaps. Without it the quick comment signs are never defined, so quick_comments.signs.enabled has no effect.
{
"vuki656/review.nvim",
opts = {},
}With options:
{
"vuki656/review.nvim",
config = function()
require("review").setup({
keymaps = { toggle = "<leader>rv" },
})
end,
}To lazy-load, give lazy.nvim its own trigger. The plugin is not loaded until that trigger fires, so keymaps.toggle does not exist yet either. Use lazy's keys instead:
{
"vuki656/review.nvim",
cmd = "Review",
keys = { { "<leader>rv", "<cmd>Review<cr>", desc = "Toggle review" } },
opts = {},
}use({ "vuki656/review.nvim", config = function() require("review").setup({}) end })Plug 'vuki656/review.nvim'
" after plug#end():
lua require("review").setup({}):Review opens a dedicated tab of floating windows. A sidebar on the left, the diff on the right.
┌─ Branch ────────┐┌───────────────────────────────┐
├─ Files ─────────┤│ │
│ ││ │
├─ Branches ──────┤│ diff │
│ ││ │
├─ Commits ───────┤│ │
│ ││ │
├─ Comments ──────┤│ │
└─────────────────┘└───────────────────────────────┘
Branch is a read-only line showing the current branch. Files, Branches, Commits and Comments are focusable. <Tab> cycles Files → Branches → Commits → Comments → Files; h/l walk the same chain with wrapping; <C-l> jumps from any sidebar panel to the diff, <C-h> from the diff back to Files. Set ui.number_navigation = true to use 1–n to focus the sidebar panels top to bottom, and 0 for the diff.
| Command | Description |
|---|---|
:Review |
Toggle the review UI |
:Review close |
Close the review UI |
:Review export |
Copy all comments to the clipboard as markdown |
:Review send [target] |
Send comments to export.on_export, or to a tmux pane (defaults to tmux.target) |
:Review commit <sha> |
Set the diff base to <sha> |
:Review pick [count] |
Pick a base commit from the last count commits (default 20) |
:Review clear |
Clear all review comments |
:Review qc |
Add a quick comment on the current line of the current buffer, or on a range with :'<,'>Review qc |
:Review qp |
Toggle the quick comments panel |
:Review qs [target] |
Send quick comments to export.on_export, or to a tmux pane (defaults to tmux.target) |
:Review log |
Open the plugin log file in a new tab |
:checkhealth review verifies the Neovim version, git and the repository, tmux and $TMUX, whether setup() has run, the log level, and the log file path. The "setup() has not been called" result is a warning, not an error. The defaults are in effect either way.
Lua API:
local review = require("review")
review.setup(opts)
review.toggle()
review.open()
review.close()
review.clear_comments() -- clear all review comments
review.export() -- to clipboard
review.send(target) -- to export.on_export, else tmux; target optional
review.quick_send(target, opts) -- send quick comments; opts.clear, opts.silent
review.is_open() -- boolean
review.get_state() -- current state table
review.reset() -- reset stateQuick comments have their own module:
local qc = require("review.quick_comments")
qc.add() -- comment on the current line
qc.add(42, 50) -- comment on lines 42 to 50
qc.add_visual() -- comment on the current visual selection
qc.toggle_panel()
qc.export() -- copy to clipboard
qc.send(target, opts) -- to export.on_export, else tmux; opts.clear, opts.silent
qc.copy() -- copy to clipboard, then clear all quick commentsAll keymaps are buffer-local to the review UI. Press ? in the Files, Branches, Commits or Comments panel, or in the diff pane, for the in-plugin help overlay listing that panel's keymaps.
Numeric section navigation is disabled by default. When ui.number_navigation is enabled, 1–n focus the sidebar panels top to bottom and 0 focuses the diff pane. These mappings are only active inside the review UI; in side-by-side mode, 0 focuses the new (right) diff pane.
| Key | Action |
|---|---|
j / k |
Next / previous entry (skips headers, loads its diff) |
<CR> |
On a file: load the diff and focus the diff pane. On a directory: collapse/expand |
<Space> |
Stage / unstage. On a directory or the / root, applies to everything under it |
e |
Open the real file at its first change, closing the review |
L |
Show the full path in a popup |
R |
Refresh the file list |
` |
Toggle tree / flat list view |
S |
Toggle unified / side-by-side diff |
{ / } |
Shrink / expand diff context lines |
c |
Commit staged changes (subject + description popup) |
A |
Amend staged changes into the last commit. With nothing staged, offers to stage everything first |
d |
Revert the file's changes (confirms first) |
P |
Push to remote |
B |
Focus the Commits panel |
<C-n> |
Hide / show the whole sidebar |
J / K |
Scroll the diff pane down / up |
<Tab> / l |
Focus the Branches panel |
<C-l> |
Focus the diff pane |
<Esc> |
Reset the diff base back to HEAD (no-op unless a branch or commit is selected) |
q |
Close the review |
? |
Help overlay |
| Key | Action |
|---|---|
c |
Add a comment on the current line, or on the selection in visual mode |
dc |
Delete the comment on the current line |
]c / [c |
Next / previous hunk |
]f / [f |
Next / previous file |
e |
Open the real file at the current line, closing the review (raises the exit menu if you have comments) |
S |
Toggle unified / side-by-side diff |
{ / } |
Shrink / expand diff context lines |
<C-n> |
Hide / show the sidebar |
<C-h> |
Focus the Files panel (in side-by-side, from the right pane focuses the left pane) |
<C-l> |
In side-by-side, from the left pane focuses the right pane |
<Esc> |
Focus the Files panel, and reset the base to HEAD if a branch or commit was selected |
q |
Close the review |
? |
Help overlay |
In side-by-side mode, c and dc are only bound on the right (new) pane. A binary file renders as a Binary file placeholder instead of an empty pane.
| Key | Mode | Action |
|---|---|---|
<CR> |
insert, normal | Submit |
<Esc> |
insert, normal | Cancel |
<C-c> |
insert | Cancel |
<S-CR> |
insert | Newline |
<Tab> / <S-Tab> |
insert | Cycle comment type: Fix → Note → Question (diff pane comments only) |
<C-t> |
insert | Template picker (diff pane comments only) |
Submitting an empty input also discards the comment. The quick comment input has no types or templates, so only <CR>, <Esc>, <C-c> and <S-CR> are bound there. In the template picker, press a template's key to apply it, or <Esc> / q / <C-t> to back out.
| Key | Action |
|---|---|
j / k |
Next / previous branch |
<CR> |
Diff the main branch against the selected branch |
<Space> |
Check out the branch (refuses on a dirty worktree) |
p |
Pull from remote |
n |
Create a new branch from the selected one |
d |
Delete the branch (confirms first) |
P |
Push to remote |
<C-d> / <C-u> |
Scroll the diff pane down / up |
<Tab> / l |
Focus the Commits panel |
h |
Focus the Files panel |
<C-l> |
Focus the diff pane |
<Esc> |
Reset the diff base back to HEAD |
q |
Close the review |
? |
Help overlay |
| Key | Action |
|---|---|
j / k |
Next / previous commit, previewing its full diff |
<CR> |
Set the diff base to that commit and browse its files |
u |
Uncommit, soft reset the most recent commit (confirms first) |
P |
Push to remote |
<C-d> / <C-u> |
Scroll the diff pane down / up |
<Tab> / l |
Focus the Comments panel |
h |
Focus the Branches panel |
<C-l> |
Focus the diff pane |
<Esc> |
Reset the diff base back to HEAD |
q |
Close the review |
? |
Help overlay |
| Key | Action |
|---|---|
j / k |
Next / previous entry |
<CR> |
Jump to the comment in the diff. On a directory in tree view: collapse/expand |
d |
Delete the comment (confirms first) |
t |
Toggle flat / tree grouping |
P |
Push to remote |
<C-d> / <C-u> |
Scroll the diff pane down / up |
<Tab> |
Focus the Files panel |
h |
Focus the Commits panel |
<C-l> |
Focus the diff pane |
<Esc> |
Reset the diff base back to HEAD |
q |
Close the review |
? |
Help overlay |
Quick comments are separate from review comments: they attach to any line of any buffer, outside the review UI, and show up as gutter signs. :Review qc adds one, :Review qp toggles the panel. Selecting lines first and running :'<,'>Review qc attaches the comment to the whole range, which exports as **Lines 42-50** with all the selected lines as context. If quick_comments.keymaps.add is set, that key works in visual mode too.
| Key | Action |
|---|---|
j / k |
Standard cursor movement |
<CR> |
Jump to the comment's file and line in the previous window |
L |
Preview the full comment text in a popup |
e |
Edit the comment |
d |
Delete the comment |
c |
Copy all quick comments to the clipboard as markdown |
s |
Send all quick comments to export.on_export, or to tmux |
q / <Esc> |
Close the panel |
Full defaults, copy-pasteable:
require("review").setup({
keymaps = {
toggle = nil,
},
diff = {
base = "HEAD",
},
ui = {
file_tree_width = 33,
diff_view_mode = "unified",
number_navigation = false,
panels = { "branch_info", "file_tree", "branch_list", "commit_list", "comment_list" },
},
tmux = {
target = "!",
auto_enter = false,
},
quick_comments = {
keymaps = {
add = nil,
toggle_panel = nil,
send = nil,
},
panel = {
width = 65,
position = "right",
},
signs = {
enabled = true,
},
},
export = {
context_lines = 3,
on_export = nil,
},
auto_refresh = {
enabled = true,
debounce_ms = 500,
},
persistence = {
enabled = true,
},
log_level = "WARN",
log_file = nil,
templates = {
{ key = "e", label = "Extract", text = "Extract this into a separate function/component" },
{ key = "r", label = "Rename", text = "Rename to: " },
{ key = "m", label = "Move", text = "Move this to a separate file" },
{ key = "t", label = "Types", text = "Add proper types" },
{ key = "h", label = "Error handling", text = "Add error handling" },
{ key = "p", label = "Performance", text = "Performance concern: " },
{ key = "s", label = "Simplify", text = "Simplify this" },
{ key = "d", label = "Delete", text = "Remove this" },
},
})The nil entries are unset by default. No global keymaps are created unless you give them a value, and log_file falls back to the temp path described below.
keymaps.toggle: global normal-mode key that toggles the review UI.diff.base: git revision the diff compares against."HEAD"means "everything in the worktree".ui.file_tree_width: sidebar width as a percentage of total columns, not a column count.ui.diff_view_mode:"unified"or"split"(side-by-side) on open.Stoggles at runtime.ui.number_navigation: enable1–nto focus the sidebar panels top to bottom and0to focus the diff. Disabled by default so normal-mode counts and0retain their usual behavior unless you opt in.ui.panels: sidebar panels to display. Can be a list of panel names (e.g.{ "file_tree", "comment_list" }or{ "files", "comments" }) or a table of boolean toggles (e.g.{ branch = false, branches = false, commits = false }). Defaults to showing all panels (branch_info,file_tree,branch_list,commit_list,comment_list). Note that the Files panel (file_tree) cannot be disabled.tmux.target: tmux target that:Review sendand:Review qspaste into. The default"!"is tmux's last active pane, which is normally the pane you came from, usually the one running your agent. Any targettmux paste-buffer -taccepts works instead, e.g. a named window"CLAUDE","CLAUDE.0"or a fully qualified"session:window.pane".tmux.auto_enter: sendEnterafter pasting. Off by default so you can read the prompt before submitting it.quick_comments.keymaps.add/.toggle_panel/.send: global keys for:Review qc,:Review qp, and:Review qs.quick_comments.signs.enabled: gutter signs for quick comments.export.context_lines: lines of diff context included above and below each comment in the exported markdown.export.on_export: your own delivery callback,function(content, comments).contentis the exported markdown,commentsthe comment tables it was built from. Returnfalse(or raise) to say the hand-off failed. When it is set,:Review sendand "Copy & Send" call it instead of pasting into tmux. The clipboard paths,:Review exportand "Exit & Copy", still copy and then call it as well. A failed hand-off on close keeps the saved session instead of deleting it, so nothing is lost.auto_refresh: a filesystem watcher re-renders the UI when files change on disk, debounced bydebounce_ms. On macOS and Windows it watches the git root with a single recursive handle. On Linux (no recursive watching in libuv) it walks the git root and watches each directory individually, stopping at 2000 directories; past that a warning goes to the log and the rest of the tree is not watched, and the directory list is built when the UI opens, so directories created afterwards are picked up on the next open. Changes under.git,node_modules,target,dist,build,.venvandvendorare ignored everywhere. Useful when an agent is writing while you read.persistence.enabled: remembers comments across sessions. State lives in.git/review-session.jsonand.git/review-comments.json, so nothing needs gitignoring.log_level:"DEBUG","INFO","WARN"or"ERROR".log_file: path to write the log to. Unset by default, in which case the log goes toreview.nvim/review.logunder the system temp directory (vim.uv.os_tmpdir(), typically/tmp/review.nvim/review.log) so it gets cleaned up with the rest of temp. Set this to keep the log somewhere persistent. Either way the file rotates: once it passes 1 MB it is moved to<path>.oldand a fresh one is started.:Review logopens the current file.templates: canned comment texts reachable with<C-t>in the diff comment input. Atextending in": "leaves the cursor at the end for you to finish; anything else submits immediately.
The loop:
-
:Reviewopens the diff againstHEAD, so you see whatever the agent just wrote. -
Read the diff. Press
con a line to attach a comment,<Tab>to pick its type (Fix / Note / Question),<CR>to submit. Comments render as boxed virtual lines under the code and collect in the Comments panel. -
<Space>on files in the Files panel to stage the parts you're keeping. -
qto close. If you have comments, an exit popup appears:- Exit, Copy & Send to tmux: copies to the clipboard and pastes into the tmux target.
- Exit & Copy: clipboard only.
- Exit: keeps the session so
:Reviewpicks up where you left off.
The two copy options clear the saved session. With no comments,
qexits straight away. -
Paste into the agent, or let tmux do it for you.
:Review export and :Review send [target] do the same export without closing the UI.
The generated markdown looks like:
# Code Review Comments
## src/api/client.ts
### [FIX] src/api/client.ts:42
```typescript
const res = await fetch(url)
+return res.json()
}
```
Handle a non-2xx response here.To hand the export to your own workflow instead of pasting it somewhere, set export.on_export:
require("review").setup({
export = {
on_export = function(content, comments)
local path = vim.fn.stdpath("state") .. "/review-latest.md"
if vim.fn.writefile(vim.split(content, "\n"), path) ~= 0 then
return false
end
vim.system({ "my-agent", "--file", path, "--count", tostring(#comments) })
return true
end,
},
})For the tmux path, tmux.target names where the markdown is pasted. The default "!" is tmux's last active pane, so the export lands in whatever pane you were in before Neovim, usually the one running your agent. If your agent lives somewhere fixed, set a name instead (target = "CLAUDE", or a fully qualified "session:window.pane"), or pass one per call with :Review send other-pane. "Copy & Send" fails quietly outside tmux, you still get the clipboard copy.
MIT
