opencode-nvim-diff-review 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,36 +1,38 @@
1
1
  # nvim-diff-review-opencode-plugin
2
2
 
3
- An agent-driven guided code review tool for Neovim and [OpenCode](https://opencode.ai). An AI agent walks you through code changes file by file, showing side-by-side diffs in Neovim while discussing the changes in a separate OpenCode chat.
3
+ An agent-driven guided code review tool for Neovim and [OpenCode](https://opencode.ai). An AI agent walks you through code changes hunk by hunk, showing side-by-side diffs in Neovim while discussing the changes in a separate OpenCode chat.
4
4
 
5
5
  ## How it works
6
6
 
7
- The plugin connects an OpenCode agent to Neovim's [diffview.nvim](https://github.com/sindrets/diffview.nvim) via Neovim's built-in RPC socket. The agent can open diffs, navigate between files, and query the current review state — all while the user interacts via the OpenCode chat.
7
+ The plugin connects an OpenCode agent to Neovim's [diffview.nvim](https://github.com/sindrets/diffview.nvim) via Neovim's built-in RPC socket. The agent can query all change hunks, reorder them for narrative coherence, open diffs, and navigate between hunks — all while the user interacts via the OpenCode chat.
8
8
 
9
9
  ```
10
10
  ┌────────────────────────────┬──────────────────────┐
11
11
  │ │ │
12
12
  │ Neovim showing diff of │ OpenCode agent: │
13
13
  │ src/store/selectors.js │ │
14
- │ "I refactored the
15
- - old code in red │ getBlockName func
16
- + new code in green to handle the new
17
- │ edge case where..."
14
+ (cursor at hunk 2 of 3) │ "I added validation
15
+ │ for the edge case
16
+ - old code in red where the block
17
+ + new code in green name is │
18
+ │ │ undefined..." │
18
19
  │ │ │
19
20
  │ │ Any questions about │
20
- │ │ these changes?
21
+ │ │ this change?
21
22
  └────────────────────────────┴──────────────────────┘
22
23
  ```
23
24
 
24
25
  The review workflow:
25
26
 
26
- 1. Agent opens a diff view in Neovim (scoped to specific files or all uncommitted changes)
27
- 2. Agent explains the changes in the current file
28
- 3. User asks questions or leaves feedback agent notes it but **does not edit files**
29
- 4. Agent navigates to the next file and repeats
30
- 5. After the last file, agent closes the diff view
31
- 6. Agent proposes a commit message and commits the original work
32
- 7. If feedback was left, agent applies it as a separate commit
33
- 8. Optionally, a second review of just the feedback changes
27
+ 1. Agent queries all change hunks across all files
28
+ 2. Agent decides a review order reordering for narrative coherence (e.g., data model first, then API, then UI) or using natural order for small changes
29
+ 3. Agent opens the diff view and begins the review walk-through
30
+ 4. For each hunk: agent explains the change, user asks questions or leaves feedback
31
+ 5. Agent notes feedback but **does not edit files** during the review
32
+ 6. After the last hunk, agent closes the diff view
33
+ 7. Agent proposes a commit message and commits the original work
34
+ 8. If feedback was left, agent applies it as a separate commit
35
+ 9. Optionally, a second review of just the feedback changes
34
36
 
35
37
  ## Components
36
38
 
@@ -40,7 +42,10 @@ The plugin has two parts that are installed separately:
40
42
 
41
43
  A Lua module that:
42
44
 
43
- - Registers a global `DiffviewState()` function queryable via Neovim's RPC socket
45
+ - Registers global functions queryable via Neovim's RPC socket:
46
+ - `DiffviewState()` — current diffview state (file, position, file list)
47
+ - `DiffviewHunks(ref?)` — all diff hunks as a flat array, parsed from `git diff` output using diffview.nvim's diff parser
48
+ - `DiffviewGoTo(file, line)` — navigate diffview to a specific file and line
44
49
  - Provides diffview.nvim hooks that clean up buffers when the diff view is closed (so reviewed files don't linger as open tabs)
45
50
 
46
51
  ### 2. OpenCode plugin (`opencode-plugin/index.ts`)
@@ -49,11 +54,12 @@ An OpenCode plugin that registers a `diff_review` tool the AI agent uses to cont
49
54
 
50
55
  | Action | Description |
51
56
  |--------|-------------|
52
- | `open` | Open a diff view, optionally scoped to specific files or a git ref. Returns the file list and current position. |
53
- | `next` | Navigate to the next file. Detects and prevents wrap-around at the last file. |
54
- | `prev` | Navigate to the previous file. Detects and prevents wrap-around at the first file. |
55
- | `status` | Get current file and position without navigating. Includes the full file list. |
56
- | `close` | Close the diff view and clean up buffers. |
57
+ | `get_hunks` | Get all diff hunks across all files as a flat array. Each hunk is self-contained with file path, status, and line ranges. Optionally scoped to specific files or a git ref. |
58
+ | `start_review` | Open the diff view and begin the review walk-through. Accepts an optional ordered array of hunks (from `get_hunks`) to control review order. If omitted, uses natural hunk order. Navigates to the first item. |
59
+ | `next` | Navigate to the next item in the review queue. Prevents wrap-around at the last item. |
60
+ | `prev` | Navigate to the previous item in the review queue. Prevents wrap-around at the first item. |
61
+ | `status` | Get current position in the review queue without navigating. |
62
+ | `close` | Close the diff view and clear the review queue. |
57
63
 
58
64
  ## Dependencies
59
65
 
@@ -140,13 +146,33 @@ OpenCode (agent) Neovim (editor)
140
146
 
141
147
  The tool uses Neovim's `--remote-expr` to evaluate Lua expressions on the running Neovim instance. This is a standard Neovim feature that works in any terminal — no CMUX or specific terminal emulator required.
142
148
 
143
- Key design decisions:
149
+ ### Hunk-based review
150
+
151
+ The review operates at the **hunk level** rather than the file level. This means:
152
+
153
+ - A file with 3 separate change regions is presented as 3 review items
154
+ - The agent can reorder hunks across files for narrative coherence (e.g., show a data model change in `model.ts` before the API endpoint in `api.ts` that uses it, even if they're in different files)
155
+ - Small files with a single change are naturally one review item
156
+ - The agent can filter out trivial hunks (e.g., import reordering) from the review
157
+
158
+ Hunks are retrieved by running `git diff -U0` and parsing the output with diffview.nvim's built-in unified diff parser (`diffview.vcs.utils.parse_diff`). The `-U0` flag produces zero-context hunks, giving exact change boundaries.
159
+
160
+ ### Review queue
161
+
162
+ The review queue is managed on the TypeScript (OpenCode plugin) side. It holds:
163
+
164
+ - An ordered array of hunk items (set by `start_review`)
165
+ - The current position in the queue (advanced by `next`/`prev`)
166
+
167
+ The Lua (Neovim) side is stateless — it provides functions to query hunks and navigate the diff view, but doesn't track review progress. This keeps the Neovim plugin simple and the state management in one place.
168
+
169
+ ### Key design decisions
144
170
 
145
171
  - **`--headless` flag on remote calls**: Prevents Neovim from emitting terminal escape sequences when invoked from a subprocess (e.g., Bun's shell). Without this, the JSON response gets polluted with control codes.
146
- - **State queries via `DiffviewState()`**: A global Lua function registered at plugin load time. Returns JSON with the current file, position, and full file list. Registered as a global (not module-scoped) so it can be called via `luaeval()` without needing the module require path.
147
- - **Wrap-around prevention**: diffview.nvim wraps from the last file to the first (and vice versa) when navigating. The tool detects this by comparing indices before and after navigation, and undoes the wrap if detected.
172
+ - **State queries via global Lua functions**: `DiffviewState()`, `DiffviewHunks()`, and `DiffviewGoTo()` are registered as globals so they can be called via `luaeval()` from Neovim's `--remote-expr` without needing the module require path.
173
+ - **Wrap-around prevention**: The tool checks queue bounds before navigating and refuses to advance past the first/last item.
148
174
  - **Buffer cleanup on close**: diffview.nvim intentionally keeps local file buffers open after closing (so you can continue editing). The plugin tracks which buffers existed before the review and removes any new ones on close — unless they have unsaved edits.
149
- - **Small delays after navigation**: 200-500ms sleeps after diffview commands to let the UI update before querying state. Without this, the state query can return stale data.
175
+ - **Small delays after navigation**: 300ms sleeps after diffview commands to let the UI update before querying state. Without this, the state query can return stale data.
150
176
  - **Socket auto-discovery**: When `NVIM_SOCKET` is not set, the tool scans `$TMPDIR/nvim.$USER/` and `/tmp` for Neovim socket files, verifies each is live, and uses `lsof` to match the Neovim process's working directory against the current project. This allows zero-configuration usage in ad-hoc terminals — just run `nvim` and OpenCode will find it.
151
177
 
152
178
  ### Review workflow instructions
@@ -154,29 +180,26 @@ Key design decisions:
154
180
  The tool description embeds detailed workflow instructions for the AI agent:
155
181
 
156
182
  - **Lint before review**: The agent is told to fix lint/format issues before opening the diff, so the user only sees clean changes.
183
+ - **Narrative ordering**: The agent analyzes hunks and reorders them for coherent presentation — explaining foundational changes before dependent ones.
157
184
  - **No edits during review**: The agent is explicitly instructed to never edit files during the review. Feedback is collected and applied afterward.
158
185
  - **Two-commit pattern**: Original work is committed first, then feedback changes are committed separately. This gives clean git history and allows the second review to show only the feedback diff.
159
- - **Interactive pacing**: The agent explains each file's changes, asks for feedback, and waits for the user's response before moving on.
186
+ - **Interactive pacing**: The agent explains each hunk, asks for feedback, and waits for the user's response before moving on.
160
187
 
161
188
  ## Future improvements
162
189
 
163
190
  These were discussed during development but not yet implemented:
164
191
 
165
- ### Chunk-level navigation
166
-
167
- Navigate within a file between individual hunks (like `git add -p`), not just between files. This would allow the agent to walk through a large file's changes piece by piece rather than presenting the whole diff at once.
192
+ ### Expand (surrounding context)
168
193
 
169
- ### Logical ordering
170
-
171
- Instead of reviewing files in filesystem order, the agent would use its understanding of the codebase to present changes in a narrative order — e.g., "first the data model change, then the API that uses it, then the UI that calls the API." This would make reviews of larger changesets more coherent.
194
+ Show surrounding code context around the current hunk. Useful when the agent or user needs to see more of the file to understand a change. Would be implemented as an `expand` action.
172
195
 
173
196
  ### Accept/reject per-hunk
174
197
 
175
198
  Allow the user to accept or reject individual hunks from the diff view, similar to `git add -p`. This would integrate with diffview.nvim's staging capabilities.
176
199
 
177
- ### Line range focus
200
+ ### Logical ordering
178
201
 
179
- The agent could jump to specific line ranges within a diff to highlight the key change, rather than showing the full file diff. Useful for large files where only a small section was modified.
202
+ Instead of the agent deciding order ad-hoc, build smarter ordering heuristics e.g., analyzing import graphs to automatically determine dependency order between changed files.
180
203
 
181
204
  ### OpenCode session diff integration
182
205
 
@@ -3,12 +3,19 @@
3
3
  -- Provides:
4
4
  -- 1. A global DiffviewState() function that external tools can query via Neovim's
5
5
  -- RPC socket to get the current diffview state (file, position, file list).
6
- -- 2. Diffview hooks that clean up buffers when the diff view is closed.
6
+ -- 2. A global DiffviewHunks() function that returns all diff hunks across all files
7
+ -- as a flat array, using diffview.nvim's diff parser on git diff output.
8
+ -- 3. A global DiffviewGoTo(file, line) function that navigates diffview to a
9
+ -- specific file and line number.
10
+ -- 4. Diffview hooks that clean up buffers when the diff view is closed.
7
11
  --
8
12
  -- Requires: sindrets/diffview.nvim
9
13
  --
10
14
  -- Usage from outside Neovim:
11
15
  -- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewState()')"
16
+ -- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewHunks()')"
17
+ -- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewHunks(\"HEAD~3\")')"
18
+ -- nvim --headless --server $NVIM_SOCKET --remote-expr "luaeval('DiffviewGoTo(\"src/foo.ts\", 42)')"
12
19
 
13
20
  local M = {}
14
21
 
@@ -16,6 +23,13 @@ local M = {}
16
23
  --- Used by the cleanup hooks to avoid removing buffers the user had open.
17
24
  local pre_diffview_bufs = {}
18
25
 
26
+ --- Pending cursor position to set after diffview finishes opening a file.
27
+ --- DiffviewGoTo stores the target here, and the autocmd on DiffviewDiffBufWinEnter
28
+ --- applies it. This avoids a race with diffview's async file loading which resets
29
+ --- the cursor to line 1 after opening.
30
+ --- @type { file: string, line: number }?
31
+ local pending_goto = nil
32
+
19
33
  --- Query the current diffview state.
20
34
  --- Returns a JSON string with:
21
35
  --- open: boolean - whether diffview is active
@@ -68,6 +82,182 @@ function DiffviewState()
68
82
  })
69
83
  end
70
84
 
85
+ --- Get all diff hunks across all files as a flat array.
86
+ ---
87
+ --- Runs `git diff` and parses the output using diffview.nvim's diff parser.
88
+ --- Each hunk is a self-contained object with its file path, git status, and
89
+ --- line range information.
90
+ ---
91
+ --- @param ref string? Optional git ref to diff against (e.g. "HEAD~3").
92
+ --- Defaults to diffing uncommitted changes vs HEAD.
93
+ --- If diffview is open and has a rev_arg, that is used as
94
+ --- the default instead.
95
+ --- @return string JSON-encoded flat array of hunk objects:
96
+ --- [{ file, status, old_start, old_count, new_start, new_count, header }]
97
+ function DiffviewHunks(ref)
98
+ local ok, lib = pcall(require, "diffview.lib")
99
+ if not ok then
100
+ return vim.json.encode({ error = "diffview.nvim not loaded" })
101
+ end
102
+
103
+ local vcs_utils = require("diffview.vcs.utils")
104
+
105
+ -- Determine the git toplevel directory
106
+ local toplevel = vim.fn.systemlist("git rev-parse --show-toplevel")[1]
107
+ if vim.v.shell_error ~= 0 or not toplevel then
108
+ return vim.json.encode({ error = "Not in a git repository" })
109
+ end
110
+
111
+ -- If diffview is open, try to use its rev_arg as the default ref
112
+ if not ref then
113
+ local view = lib.get_current_view()
114
+ if view and view.rev_arg and view.rev_arg ~= "" then
115
+ ref = view.rev_arg
116
+ end
117
+ end
118
+
119
+ -- Get git status for each file (to include status letters in hunk data)
120
+ -- Use --name-status with the diff to get file statuses
121
+ local status_cmd = "git diff --name-status"
122
+ if ref then
123
+ status_cmd = status_cmd .. " " .. ref
124
+ end
125
+ local status_lines = vim.fn.systemlist(status_cmd)
126
+ local file_statuses = {}
127
+ for _, line in ipairs(status_lines) do
128
+ local status, path = line:match("^(%a)%s+(.+)$")
129
+ if status and path then
130
+ file_statuses[path] = status
131
+ end
132
+ end
133
+
134
+ -- Run git diff to get the full patch output
135
+ local diff_cmd = "git diff -U0"
136
+ if ref then
137
+ diff_cmd = diff_cmd .. " " .. ref
138
+ end
139
+ local diff_lines = vim.fn.systemlist(diff_cmd)
140
+ if vim.v.shell_error ~= 0 then
141
+ local err_msg = table.concat(diff_lines, "\n")
142
+ return vim.json.encode({
143
+ error = "git diff failed: " .. (err_msg ~= "" and err_msg or "unknown error"),
144
+ })
145
+ end
146
+
147
+ if #diff_lines == 0 then
148
+ return vim.json.encode({})
149
+ end
150
+
151
+ -- Normalize hunk headers for -U0 output.
152
+ -- With -U0, git omits the count when it's 1 (e.g., "@@ -134 +134,4 @@"
153
+ -- instead of "@@ -134,1 +134,4 @@"). diffview's parser expects the
154
+ -- comma-separated form, so we normalize before parsing.
155
+ for i, line in ipairs(diff_lines) do
156
+ local old_spec, new_spec = line:match("^@@ %-(%S+) %+(%S+) @@")
157
+ if old_spec then
158
+ -- Ensure both sides have the ",count" format
159
+ if not old_spec:match(",") then
160
+ old_spec = old_spec .. ",1"
161
+ end
162
+ if not new_spec:match(",") then
163
+ new_spec = new_spec .. ",1"
164
+ end
165
+ diff_lines[i] = "@@ -" .. old_spec .. " +" .. new_spec .. " @@"
166
+ end
167
+ end
168
+
169
+ -- Parse the diff using diffview's parser
170
+ local file_diffs = vcs_utils.parse_diff(diff_lines)
171
+
172
+ -- Flatten into a single array: one entry per hunk, each with its file info
173
+ local hunks = {}
174
+ for _, file_diff in ipairs(file_diffs) do
175
+ local path = file_diff.path_new or file_diff.path_old or ""
176
+ local status = file_statuses[path] or "M"
177
+
178
+ for _, hunk in ipairs(file_diff.hunks) do
179
+ local header = string.format(
180
+ "@@ -%d,%d +%d,%d @@",
181
+ hunk.old_row, hunk.old_size, hunk.new_row, hunk.new_size
182
+ )
183
+ table.insert(hunks, {
184
+ file = path,
185
+ status = status,
186
+ old_start = hunk.old_row,
187
+ old_count = hunk.old_size,
188
+ new_start = hunk.new_row,
189
+ new_count = hunk.new_size,
190
+ header = header,
191
+ })
192
+ end
193
+ end
194
+
195
+ return vim.json.encode(hunks)
196
+ end
197
+
198
+ --- Navigate diffview to a specific file and hunk.
199
+ ---
200
+ --- Finds the file in diffview's file list, switches to it if needed,
201
+ --- positions the cursor at the hunk, and folds all other regions so only
202
+ --- the target hunk (with a few context lines) is visible.
203
+ ---
204
+ --- @param file string Repo-relative file path
205
+ --- @param line_or_hunk number|table Either a line number or a hunk spec table:
206
+ --- { new_start, new_count, old_start, old_count }
207
+ --- @return string JSON-encoded result: { ok: true } or { error: string }
208
+ function DiffviewGoTo(file, line_or_hunk)
209
+ local ok, lib = pcall(require, "diffview.lib")
210
+ if not ok then
211
+ return vim.json.encode({ error = "diffview.nvim not loaded" })
212
+ end
213
+
214
+ local view = lib.get_current_view()
215
+ if not view then
216
+ return vim.json.encode({ error = "diffview is not open" })
217
+ end
218
+
219
+ local utils = require("diffview.utils")
220
+ local panel = view.panel
221
+ local files = panel:ordered_file_list()
222
+
223
+ -- Find the target file in the file list
224
+ local target = nil
225
+ for _, f in ipairs(files) do
226
+ if f.path == file then
227
+ target = f
228
+ break
229
+ end
230
+ end
231
+
232
+ if not target then
233
+ return vim.json.encode({ error = "File not found in diffview: " .. file })
234
+ end
235
+
236
+ -- Store the target so the autocmd can position the cursor and set up folds
237
+ -- after diffview finishes its async file loading.
238
+ local hunk_spec = type(line_or_hunk) == "table" and line_or_hunk or nil
239
+ local target_line = hunk_spec
240
+ and (hunk_spec.new_start > 0 and hunk_spec.new_start or 1)
241
+ or (type(line_or_hunk) == "number" and line_or_hunk or 1)
242
+
243
+ pending_goto = {
244
+ file = file,
245
+ line = target_line,
246
+ hunk = hunk_spec,
247
+ }
248
+
249
+ -- Switch to the target file if it's not already the current one
250
+ local cur_file = panel.cur_file
251
+ if not cur_file or cur_file.path ~= file then
252
+ view:set_file(target)
253
+ else
254
+ -- Already on the right file — apply directly
255
+ M._apply_pending_goto()
256
+ end
257
+
258
+ return vim.json.encode({ ok = true })
259
+ end
260
+
71
261
  --- Diffview hook: called when a diff view is opened.
72
262
  --- Snapshots the current buffer list so we know which buffers to clean up later.
73
263
  function M.on_view_opened(view)
@@ -79,6 +269,130 @@ function M.on_view_opened(view)
79
269
  end
80
270
  end
81
271
 
272
+ --- Number of context lines to show above and below a focused hunk.
273
+ local HUNK_CONTEXT = 3
274
+
275
+ --- Apply a pending cursor position and hunk focus after diffview has finished
276
+ --- loading a file. Called from the DiffviewDiffBufWinEnter autocmd and from
277
+ --- DiffviewGoTo when the file is already displayed.
278
+ function M._apply_pending_goto()
279
+ if not pending_goto then return end
280
+
281
+ local target_line = pending_goto.line
282
+ local hunk = pending_goto.hunk
283
+ pending_goto = nil
284
+
285
+ -- Small delay to ensure diffview's own cursor positioning (which resets to
286
+ -- line 1 on file_open_new) has completed before we override it.
287
+ vim.defer_fn(function()
288
+ local ok, lib = pcall(require, "diffview.lib")
289
+ if not ok then return end
290
+
291
+ local view = lib.get_current_view()
292
+ if not view then return end
293
+
294
+ local layout = view.cur_layout
295
+ if not layout then return end
296
+
297
+ -- Get the main window (right-hand / "b" side in a 2-way diff)
298
+ local main_win = layout:get_main_win()
299
+ if not main_win or not main_win.id or not vim.api.nvim_win_is_valid(main_win.id) then
300
+ return
301
+ end
302
+
303
+ local main_file = main_win.file
304
+ if not main_file or not main_file.bufnr or not vim.api.nvim_buf_is_loaded(main_file.bufnr) then
305
+ return
306
+ end
307
+
308
+ -- Position cursor at the hunk
309
+ local max_line = vim.api.nvim_buf_line_count(main_file.bufnr)
310
+ local line = math.min(math.max(target_line or 1, 1), max_line)
311
+ vim.api.nvim_win_set_cursor(main_win.id, { line, 0 })
312
+ vim.api.nvim_set_current_win(main_win.id)
313
+
314
+ -- Set up hunk-focus folds if we have hunk boundaries
315
+ if hunk then
316
+ M._apply_hunk_focus(view, hunk)
317
+ end
318
+
319
+ vim.cmd("normal! zz")
320
+ end, 50)
321
+ end
322
+
323
+ --- Create folds that hide everything except the target hunk and a few
324
+ --- context lines around it. Switches both diff windows to foldmethod=manual.
325
+ ---
326
+ --- @param view table The current DiffView
327
+ --- @param hunk table Hunk spec: { new_start, new_count, old_start, old_count }
328
+ function M._apply_hunk_focus(view, hunk)
329
+ local layout = view.cur_layout
330
+ if not layout then return end
331
+
332
+ -- Compute the visible range for each side (old = "a", new = "b").
333
+ -- layout.a and layout.b are Window objects with .id and .file properties.
334
+ -- A hunk with count=0 means pure insertion/deletion — show context around
335
+ -- the start line instead.
336
+ local sides = {}
337
+
338
+ -- "b" side (new/right) — uses new_start/new_count
339
+ if layout.b and layout.b.file and layout.b.file.bufnr
340
+ and vim.api.nvim_buf_is_loaded(layout.b.file.bufnr)
341
+ then
342
+ local lcount = vim.api.nvim_buf_line_count(layout.b.file.bufnr)
343
+ local hunk_first = hunk.new_start > 0 and hunk.new_start or 1
344
+ local hunk_last = hunk.new_count > 0 and (hunk.new_start + hunk.new_count - 1) or hunk_first
345
+ table.insert(sides, {
346
+ win_id = layout.b.id,
347
+ lcount = lcount,
348
+ hunk_first = hunk_first,
349
+ hunk_last = hunk_last,
350
+ })
351
+ end
352
+
353
+ -- "a" side (old/left) — uses old_start/old_count
354
+ if layout.a and layout.a.file and layout.a.file.bufnr
355
+ and vim.api.nvim_buf_is_loaded(layout.a.file.bufnr)
356
+ then
357
+ local lcount = vim.api.nvim_buf_line_count(layout.a.file.bufnr)
358
+ local hunk_first = hunk.old_start > 0 and hunk.old_start or 1
359
+ local hunk_last = hunk.old_count > 0 and (hunk.old_start + hunk.old_count - 1) or hunk_first
360
+ table.insert(sides, {
361
+ win_id = layout.a.id,
362
+ lcount = lcount,
363
+ hunk_first = hunk_first,
364
+ hunk_last = hunk_last,
365
+ })
366
+ end
367
+
368
+ for _, side in ipairs(sides) do
369
+ if vim.api.nvim_win_is_valid(side.win_id) then
370
+ vim.api.nvim_win_call(side.win_id, function()
371
+ -- Switch to manual folds so we have full control
372
+ vim.wo.foldmethod = "manual"
373
+ vim.wo.foldenable = true
374
+
375
+ -- Remove all existing folds
376
+ pcall(vim.cmd, "normal! zE")
377
+
378
+ -- Visible range: hunk lines + context
379
+ local vis_first = math.max(1, side.hunk_first - HUNK_CONTEXT)
380
+ local vis_last = math.min(side.lcount, side.hunk_last + HUNK_CONTEXT)
381
+
382
+ -- Create fold above the visible range
383
+ if vis_first > 1 then
384
+ vim.cmd(string.format("1,%dfold", vis_first - 1))
385
+ end
386
+
387
+ -- Create fold below the visible range
388
+ if vis_last < side.lcount then
389
+ vim.cmd(string.format("%d,%dfold", vis_last + 1, side.lcount))
390
+ end
391
+ end)
392
+ end
393
+ end
394
+ end
395
+
82
396
  --- Diffview hook: called when a diff view is closed.
83
397
  --- Cleans up buffers that diffview created but the user didn't have open before.
84
398
  --- - diffview:// internal buffers are always removed
@@ -103,7 +417,8 @@ function M.on_view_closed(view)
103
417
  end
104
418
 
105
419
  --- Set up the plugin. Call this from your plugin spec or init.lua.
106
- --- Configures diffview.nvim hooks for buffer cleanup.
420
+ --- Configures diffview.nvim hooks for buffer cleanup and registers global
421
+ --- functions for external tool access.
107
422
  ---
108
423
  --- Example with lazy.nvim:
109
424
  --- {
@@ -116,9 +431,20 @@ end
116
431
  function M.setup(opts)
117
432
  opts = opts or {}
118
433
 
119
- -- Register the DiffviewState global function (already done at module load,
120
- -- but this ensures it's available even if the module is lazy-loaded)
434
+ -- Register global functions (already defined at module load,
435
+ -- but this ensures they're available even if the module is lazy-loaded)
121
436
  _G.DiffviewState = DiffviewState
437
+ _G.DiffviewHunks = DiffviewHunks
438
+ _G.DiffviewGoTo = DiffviewGoTo
439
+
440
+ -- Listen for diffview's DiffviewDiffBufWinEnter autocmd to apply pending
441
+ -- cursor positions after async file loading completes.
442
+ vim.api.nvim_create_autocmd("User", {
443
+ pattern = "DiffviewDiffBufWinEnter",
444
+ callback = function()
445
+ M._apply_pending_goto()
446
+ end,
447
+ })
122
448
 
123
449
  -- Configure diffview hooks
124
450
  local dv_ok, diffview = pcall(require, "diffview")
@@ -1,8 +1,15 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin"
2
2
 
3
- interface DiffviewFileInfo {
4
- path: string
3
+ // --- Types ---
4
+
5
+ interface HunkItem {
6
+ file: string
5
7
  status: string
8
+ old_start: number
9
+ old_count: number
10
+ new_start: number
11
+ new_count: number
12
+ header: string
6
13
  }
7
14
 
8
15
  interface DiffviewState {
@@ -12,10 +19,19 @@ interface DiffviewState {
12
19
  status?: string
13
20
  index?: number
14
21
  total?: number
15
- files?: DiffviewFileInfo[]
22
+ files?: { path: string; status: string }[]
16
23
  error?: string
17
24
  }
18
25
 
26
+ // --- Review queue state (persists across tool calls within a session) ---
27
+
28
+ let reviewQueue: HunkItem[] = []
29
+ let reviewPosition = -1 // -1 means review not started
30
+ let reviewRef: string | undefined
31
+ let reviewFiles: string[] | undefined
32
+
33
+ // --- Neovim socket discovery ---
34
+
19
35
  /**
20
36
  * Discover a Neovim RPC socket when NVIM_SOCKET is not explicitly set.
21
37
  *
@@ -87,6 +103,65 @@ const discoverNvimSocket = async (): Promise<string | null> => {
87
103
  return fallback
88
104
  }
89
105
 
106
+ // --- Helpers ---
107
+
108
+ const STATUS_LABELS: Record<string, string> = {
109
+ M: "modified",
110
+ A: "added",
111
+ D: "deleted",
112
+ R: "renamed",
113
+ C: "copied",
114
+ T: "type-changed",
115
+ }
116
+
117
+ const statusLabel = (status: string | undefined): string =>
118
+ (status && STATUS_LABELS[status]) ?? "changed"
119
+
120
+ const formatHunkPosition = (): string => {
121
+ if (reviewQueue.length === 0) return "No review in progress."
122
+ const item = reviewQueue[reviewPosition]
123
+ return `Reviewing: ${item.file} (${statusLabel(item.status)}) ${item.header} — item ${reviewPosition + 1} of ${reviewQueue.length}.`
124
+ }
125
+
126
+ /**
127
+ * Match an order item from the agent to a hunk in the available hunks list.
128
+ * A hunk is uniquely identified by {file, old_start, old_count, new_start, new_count}.
129
+ */
130
+ const findHunk = (
131
+ hunks: HunkItem[],
132
+ orderItem: { file: string; old_start: number; old_count: number; new_start: number; new_count: number }
133
+ ): HunkItem | undefined =>
134
+ hunks.find(
135
+ h =>
136
+ h.file === orderItem.file &&
137
+ h.old_start === orderItem.old_start &&
138
+ h.old_count === orderItem.old_count &&
139
+ h.new_start === orderItem.new_start &&
140
+ h.new_count === orderItem.new_count
141
+ )
142
+
143
+ /**
144
+ * Format a summary of the files covered in the review queue.
145
+ */
146
+ const formatQueueSummary = (queue: HunkItem[]): string => {
147
+ const fileGroups = new Map<string, { status: string; count: number }>()
148
+ for (const item of queue) {
149
+ const existing = fileGroups.get(item.file)
150
+ if (existing) {
151
+ existing.count++
152
+ } else {
153
+ fileGroups.set(item.file, { status: item.status, count: 1 })
154
+ }
155
+ }
156
+ const lines = Array.from(fileGroups.entries()).map(
157
+ ([file, { status, count }]) =>
158
+ ` ${file} (${statusLabel(status)}) — ${count} hunk${count === 1 ? "" : "s"}`
159
+ )
160
+ return `\nFiles in review:\n${lines.join("\n")}`
161
+ }
162
+
163
+ // --- Plugin ---
164
+
90
165
  export const DiffReviewPlugin: Plugin = async (ctx) => {
91
166
  return {
92
167
  tool: {
@@ -100,53 +175,77 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
100
175
  "- If you discover lint errors during review, close the diff, fix them, then restart\n\n" +
101
176
  "Workflow:\n" +
102
177
  "1. Fix any lint/format issues in your changes first\n" +
103
- "2. Call with action 'open' to show the diff (optionally scoped to specific files)\n" +
104
- " the response includes a list of ALL files that will be reviewed\n" +
105
- "3. Explain the changes visible in the current file (the response tells you which file is shown)\n" +
106
- "4. Ask the user if they have questions or feedback about these changes\n" +
107
- "5. If the user requests changes or leaves feedback on the current file, acknowledge it\n" +
108
- " and note it down but DO NOT make any changes yet. Continue the review.\n" +
109
- "6. Call with action 'next' to move to the next changed file\n" +
110
- " when you reach the last file, 'next' will tell you there are no more files\n" +
111
- "7. Repeat steps 3-6 for each file\n" +
112
- "8. Call with action 'close' when the review is complete\n" +
113
- "9. Propose a git commit message for the CURRENT changes and commit if the user approves\n" +
114
- "10. If the user left feedback or change requests during the review, NOW apply them\n" +
178
+ "2. Call with action 'get_hunks' to retrieve all change hunks across all files.\n" +
179
+ " Each hunk includes file path, status, and line range info.\n" +
180
+ "3. Analyze the hunks and decide a review order. Reorder them for narrative\n" +
181
+ " coherence — e.g. show the data model change before the API that uses it,\n" +
182
+ " then the UI that calls the API. For small changes, the natural order is fine.\n" +
183
+ " You may also filter out hunks that are trivial (e.g. import reordering).\n" +
184
+ "4. Call with action 'start_review' with the ordered hunks array to open the diff\n" +
185
+ " view and begin. If you omit the order, natural hunk order is used.\n" +
186
+ "5. Explain the current hunk shown in the diff view.\n" +
187
+ "6. Ask the user if they have questions or feedback about these changes.\n" +
188
+ "7. If the user requests changes or leaves feedback, acknowledge it and note it\n" +
189
+ " down but DO NOT make any changes yet. Continue the review.\n" +
190
+ "8. Call with action 'next' to advance to the next item in the review queue.\n" +
191
+ " When you reach the last item, 'next' will tell you there are no more items.\n" +
192
+ "9. Repeat steps 5-8 for each item\n" +
193
+ "10. Call with action 'close' when the review is complete\n" +
194
+ "11. Propose a git commit message for the CURRENT changes and commit if the user approves\n" +
195
+ "12. If the user left feedback or change requests during the review, NOW apply them\n" +
115
196
  " — this creates a clean separation: one commit for the original work,\n" +
116
197
  " a second commit for review feedback changes\n" +
117
- "11. If you made feedback changes, offer to walk through them with a second diff_review\n" +
198
+ "13. If you made feedback changes, offer to walk through them with a second diff_review\n" +
118
199
  " — since the original work is already committed, this diff will only show\n" +
119
200
  " the feedback changes, making them easy to verify\n\n" +
120
- "CRITICAL: During the review (steps 3-7), NEVER make changes to files.\n" +
201
+ "CRITICAL: During the review (steps 5-9), NEVER make changes to files.\n" +
121
202
  "Only collect feedback. Apply changes AFTER the review is closed and the\n" +
122
203
  "original work is committed.\n\n" +
123
- "Every response includes the current file path and position (e.g., '2 of 3') " +
204
+ "Every response includes the current item and position (e.g., 'item 2 of 5') " +
124
205
  "so you always know where you are in the review. Use the 'status' action " +
125
206
  "to re-orient if you lose track.",
126
207
  args: {
127
208
  action: tool.schema
128
- .enum(["open", "next", "prev", "close", "status"])
209
+ .enum(["get_hunks", "start_review", "next", "prev", "status", "close"])
129
210
  .describe(
130
- "open: show diff view in Neovim. " +
131
- "next: navigate to next changed file. " +
132
- "prev: navigate to previous changed file. " +
133
- "close: close the diff view. " +
134
- "status: get current file and position without navigating."
211
+ "get_hunks: retrieve all diff hunks across all files as a flat array. " +
212
+ "start_review: open the diff view and begin reviewing, optionally with a custom order. " +
213
+ "next: navigate to the next item in the review queue. " +
214
+ "prev: navigate to the previous item in the review queue. " +
215
+ "status: get current position in the review queue without navigating. " +
216
+ "close: close the diff view and end the review."
217
+ ),
218
+ ref: tool.schema
219
+ .string()
220
+ .optional()
221
+ .describe(
222
+ "Git ref to diff against (get_hunks and start_review only). " +
223
+ "Defaults to showing uncommitted changes vs HEAD. " +
224
+ "Examples: HEAD~3, a commit hash, origin/main"
135
225
  ),
136
226
  files: tool.schema
137
227
  .array(tool.schema.string())
138
228
  .optional()
139
229
  .describe(
140
- "File paths to include in the diff (open only). " +
141
- "Omit to show all uncommitted changes."
230
+ "File paths to include in the diff (get_hunks and start_review only). " +
231
+ "Omit to include all uncommitted changes."
142
232
  ),
143
- ref: tool.schema
144
- .string()
233
+ order: tool.schema
234
+ .array(
235
+ tool.schema.object({
236
+ file: tool.schema.string().describe("Repo-relative file path"),
237
+ old_start: tool.schema.number().describe("Start line in old version"),
238
+ old_count: tool.schema.number().describe("Line count in old version"),
239
+ new_start: tool.schema.number().describe("Start line in new version"),
240
+ new_count: tool.schema.number().describe("Line count in new version"),
241
+ })
242
+ )
145
243
  .optional()
146
244
  .describe(
147
- "Git ref to diff against (open only). " +
148
- "Defaults to showing uncommitted changes vs HEAD. " +
149
- "Examples: HEAD~3, a commit hash, origin/main"
245
+ "Custom review order (start_review only). Array of hunk identifiers " +
246
+ "from the get_hunks response, in the order you want to review them. " +
247
+ "Each item needs: file, old_start, old_count, new_start, new_count. " +
248
+ "Omit to use the natural hunk order."
150
249
  ),
151
250
  },
152
251
  async execute(args, context) {
@@ -174,117 +273,174 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
174
273
  }
175
274
  }
176
275
 
177
- const statusLabel = (status: string | undefined): string =>
178
- status === "M" ? "modified" :
179
- status === "A" ? "added" :
180
- status === "D" ? "deleted" :
181
- status === "R" ? "renamed" :
182
- status ?? "changed"
183
-
184
- const formatState = (state: DiffviewState): string => {
185
- if (!state.open) return "Diff view is not open."
186
- if (!state.current_file)
187
- return `Diff view is open but no files to show (${state.total ?? 0} files total).`
188
- return `Currently showing: ${state.current_file} (${statusLabel(state.status)}) — file ${state.index} of ${state.total}.`
276
+ const getHunks = async (ref?: string): Promise<HunkItem[]> => {
277
+ const luaArg = ref ? `"${ref.replace(/"/g, '\\"')}"` : ""
278
+ const raw = await nvimExpr(`luaeval("DiffviewHunks(${luaArg})")`)
279
+ const parsed = JSON.parse(raw.trim())
280
+ if (parsed.error) throw new Error(parsed.error)
281
+ return parsed as HunkItem[]
189
282
  }
190
283
 
191
- const formatFileList = (state: DiffviewState): string => {
192
- if (!state.files || state.files.length === 0) return ""
193
- const list = state.files
194
- .map((f, i) => ` ${i + 1}. ${f.path} (${statusLabel(f.status)})`)
195
- .join("\n")
196
- return `\nFiles to review:\n${list}`
284
+ const goToHunk = async (item: HunkItem): Promise<void> => {
285
+ const file = item.file.replace(/"/g, '\\"')
286
+ // Pass hunk boundaries so Lua can set up folds to focus on this hunk
287
+ const hunkSpec = `{new_start=${item.new_start},new_count=${item.new_count},old_start=${item.old_start},old_count=${item.old_count}}`
288
+ const raw = await nvimExpr(
289
+ `luaeval("DiffviewGoTo('${file}', ${hunkSpec})")`
290
+ )
291
+ const result = JSON.parse(raw.trim())
292
+ if (result.error) throw new Error(result.error)
293
+ // Give diffview time to switch files and the Lua side to
294
+ // position cursor and set up folds
295
+ await Bun.sleep(500)
197
296
  }
198
297
 
199
298
  try {
200
299
  switch (args.action) {
201
- case "open": {
300
+ case "get_hunks": {
301
+ // Store ref/files for later use by start_review
302
+ reviewRef = args.ref
303
+ reviewFiles = args.files
304
+
305
+ const hunks = await getHunks(args.ref)
306
+
307
+ if (hunks.length === 0) {
308
+ return "No changes found." +
309
+ (args.ref ? ` (compared against ${args.ref})` : "")
310
+ }
311
+
312
+ // Summarize: count files and hunks
313
+ const fileSet = new Set(hunks.map(h => h.file))
314
+ const summary = `Found ${hunks.length} hunk${hunks.length === 1 ? "" : "s"} ` +
315
+ `across ${fileSet.size} file${fileSet.size === 1 ? "" : "s"}` +
316
+ (args.ref ? ` (compared against ${args.ref})` : "") + ".\n\n"
317
+
318
+ return summary + JSON.stringify(hunks, null, 2)
319
+ }
320
+
321
+ case "start_review": {
322
+ // Use ref/files from get_hunks if not explicitly provided
323
+ const ref = args.ref ?? reviewRef
324
+ const files = args.files ?? reviewFiles
325
+
326
+ // Open diffview
202
327
  let cmd = "DiffviewOpen"
203
- if (args.ref) {
204
- cmd += ` ${args.ref}`
328
+ if (ref) {
329
+ cmd += ` ${ref}`
205
330
  }
206
- if (args.files && args.files.length > 0) {
207
- const escaped = args.files.map(f => f.replace(/ /g, "\\ ")).join(" ")
331
+ if (files && files.length > 0) {
332
+ const escaped = files.map(f => f.replace(/ /g, "\\ ")).join(" ")
208
333
  cmd += ` -- ${escaped}`
209
334
  }
210
335
  await nvimExpr(`luaeval("vim.cmd('${cmd.replace(/'/g, "''")}')")`)
211
- // Give diffview a moment to populate the file list
336
+ // Give diffview time to populate the file list
212
337
  await Bun.sleep(500)
213
- const state = await getState()
214
- return `Opened diff view in Neovim` +
215
- (args.ref ? ` (comparing against ${args.ref})` : " (uncommitted changes vs HEAD)") +
216
- `. ${formatState(state)}` +
217
- formatFileList(state)
338
+
339
+ // Build the review queue
340
+ if (args.order && args.order.length > 0) {
341
+ // Agent provided a custom order — resolve each item to a full hunk
342
+ const allHunks = await getHunks(ref)
343
+ const queue: HunkItem[] = []
344
+ const unmatched: string[] = []
345
+
346
+ for (const orderItem of args.order) {
347
+ const match = findHunk(allHunks, orderItem)
348
+ if (match) {
349
+ queue.push(match)
350
+ } else {
351
+ unmatched.push(`${orderItem.file} ${orderItem.old_start}→${orderItem.new_start}`)
352
+ }
353
+ }
354
+
355
+ if (queue.length === 0) {
356
+ return "Could not match any items in the provided order to actual hunks. " +
357
+ `Unmatched: ${unmatched.join(", ")}. ` +
358
+ "Call 'get_hunks' to see available hunks."
359
+ }
360
+
361
+ reviewQueue = queue
362
+
363
+ if (unmatched.length > 0) {
364
+ // Warn but proceed with what we have
365
+ }
366
+ } else {
367
+ // No custom order — use natural hunk order
368
+ reviewQueue = await getHunks(ref)
369
+
370
+ if (reviewQueue.length === 0) {
371
+ return "Opened diff view but no hunks found." +
372
+ (ref ? ` (compared against ${ref})` : "")
373
+ }
374
+ }
375
+
376
+ // Navigate to the first item
377
+ reviewPosition = 0
378
+ await goToHunk(reviewQueue[0])
379
+
380
+ return `Started review with ${reviewQueue.length} item${reviewQueue.length === 1 ? "" : "s"}` +
381
+ (ref ? ` (comparing against ${ref})` : " (uncommitted changes vs HEAD)") +
382
+ `. ${formatHunkPosition()}` +
383
+ formatQueueSummary(reviewQueue)
218
384
  }
219
385
 
220
386
  case "next": {
221
- const before = await getState()
222
- if (!before.open) return "Diff view is not open. Call with action 'open' first."
223
-
224
- // If already on the last file, don't navigate (diffview wraps around)
225
- if (before.index !== undefined && before.total !== undefined &&
226
- before.index >= before.total) {
227
- return `Already at the last file (file ${before.index} of ${before.total}). ` +
228
- `${formatState(before)} There are no more files to review. ` +
229
- "Use action 'close' to end the review."
387
+ if (reviewQueue.length === 0) {
388
+ return "No review in progress. Call 'start_review' first."
230
389
  }
231
390
 
232
- await nvimExpr(`luaeval("require('diffview').emit('select_next_entry')")`)
233
- await Bun.sleep(200)
234
- const after = await getState()
235
-
236
- // Detect wrap-around: if index went down, diffview wrapped to the beginning
237
- if (before.index !== undefined && after.index !== undefined &&
238
- after.index < before.index) {
239
- // Undo the wrap by going back
240
- await nvimExpr(`luaeval("require('diffview').emit('select_prev_entry')")`)
241
- await Bun.sleep(200)
242
- const restored = await getState()
243
- return `Already at the last file (file ${restored.index} of ${restored.total}). ` +
244
- `${formatState(restored)} There are no more files to review. ` +
391
+ if (reviewPosition >= reviewQueue.length - 1) {
392
+ return `Already at the last item (item ${reviewPosition + 1} of ${reviewQueue.length}). ` +
393
+ `${formatHunkPosition()} There are no more items to review. ` +
245
394
  "Use action 'close' to end the review."
246
395
  }
247
396
 
248
- return `Navigated to next file. ${formatState(after)}`
397
+ reviewPosition++
398
+ await goToHunk(reviewQueue[reviewPosition])
399
+
400
+ return `Navigated to next item. ${formatHunkPosition()}`
249
401
  }
250
402
 
251
403
  case "prev": {
252
- const before = await getState()
253
- if (!before.open) return "Diff view is not open. Call with action 'open' first."
254
-
255
- // If already on the first file, don't navigate (diffview wraps around)
256
- if (before.index !== undefined && before.index <= 1) {
257
- return `Already at the first file (file ${before.index} of ${before.total}). ` +
258
- `${formatState(before)} There are no previous files.`
404
+ if (reviewQueue.length === 0) {
405
+ return "No review in progress. Call 'start_review' first."
259
406
  }
260
407
 
261
- await nvimExpr(`luaeval("require('diffview').emit('select_prev_entry')")`)
262
- await Bun.sleep(200)
263
- const after = await getState()
264
-
265
- // Detect wrap-around: if index went up, diffview wrapped to the end
266
- if (before.index !== undefined && after.index !== undefined &&
267
- after.index > before.index) {
268
- // Undo the wrap by going forward
269
- await nvimExpr(`luaeval("require('diffview').emit('select_next_entry')")`)
270
- await Bun.sleep(200)
271
- const restored = await getState()
272
- return `Already at the first file (file ${restored.index} of ${restored.total}). ` +
273
- `${formatState(restored)} There are no previous files.`
408
+ if (reviewPosition <= 0) {
409
+ return `Already at the first item (item ${reviewPosition + 1} of ${reviewQueue.length}). ` +
410
+ `${formatHunkPosition()} There are no previous items.`
274
411
  }
275
412
 
276
- return `Navigated to previous file. ${formatState(after)}`
413
+ reviewPosition--
414
+ await goToHunk(reviewQueue[reviewPosition])
415
+
416
+ return `Navigated to previous item. ${formatHunkPosition()}`
277
417
  }
278
418
 
279
419
  case "status": {
280
420
  const state = await getState()
281
- if (!state.open) return "Diff view is not currently open."
282
- return `${formatState(state)}${formatFileList(state)}`
421
+ if (!state.open && reviewQueue.length === 0) {
422
+ return "No review in progress and diff view is not open."
423
+ }
424
+
425
+ if (reviewQueue.length === 0) {
426
+ return "Diff view is open but no review queue. Call 'start_review' to begin."
427
+ }
428
+
429
+ return formatHunkPosition()
283
430
  }
284
431
 
285
432
  case "close": {
286
433
  await nvimExpr(`luaeval("require('diffview').close()")`)
287
- return "Closed the diff view in Neovim."
434
+
435
+ // Clear review state
436
+ const itemCount = reviewQueue.length
437
+ reviewQueue = []
438
+ reviewPosition = -1
439
+ reviewRef = undefined
440
+ reviewFiles = undefined
441
+
442
+ return `Closed the diff view and ended the review` +
443
+ (itemCount > 0 ? ` (reviewed ${itemCount} item${itemCount === 1 ? "" : "s"}).` : ".")
288
444
  }
289
445
  }
290
446
  } catch (e: any) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-nvim-diff-review",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Agent-driven guided code review for Neovim + OpenCode",
5
5
  "main": "opencode-plugin/index.ts",
6
6
  "keywords": [