opencode-nvim-diff-review 0.1.0 → 0.3.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
 
@@ -85,7 +91,7 @@ Add the plugin to your `opencode.json` configuration:
85
91
 
86
92
  ```json
87
93
  {
88
- "plugin": ["github:talldan/nvim-diff-review-opencode-plugin"]
94
+ "plugin": ["opencode-nvim-diff-review"]
89
95
  }
90
96
  ```
91
97
 
@@ -95,15 +101,29 @@ Restart OpenCode to load the plugin. The `diff_review` tool will be available to
95
101
 
96
102
  ### 3. Neovim RPC socket
97
103
 
98
- The tool communicates with Neovim via its RPC socket. You need to:
104
+ The tool communicates with Neovim via its RPC socket. In most cases, **no configuration is needed** — the tool auto-discovers running Neovim instances.
99
105
 
100
- 1. Start Neovim with a listen address:
101
- ```bash
102
- export NVIM_SOCKET=/tmp/nvim.sock
103
- nvim --listen $NVIM_SOCKET
104
- ```
106
+ #### Auto-discovery (default)
105
107
 
106
- 2. Make sure `NVIM_SOCKET` is set in the environment where OpenCode runs.
108
+ The tool automatically finds Neovim by:
109
+
110
+ 1. Checking the `NVIM_SOCKET` environment variable (if set, always used)
111
+ 2. Scanning for Neovim sockets in standard locations (`$TMPDIR` and `/tmp`)
112
+ 3. Preferring the Neovim instance whose working directory matches the current project
113
+ 4. Falling back to the first live Neovim instance found
114
+
115
+ This means if you just run `nvim` in your project directory, OpenCode will find it automatically.
116
+
117
+ #### Explicit configuration (optional)
118
+
119
+ If auto-discovery doesn't work for your setup (e.g., multiple Neovim instances in the same directory), you can set the socket path explicitly:
120
+
121
+ ```bash
122
+ export NVIM_SOCKET=/tmp/nvim.sock
123
+ nvim --listen $NVIM_SOCKET
124
+ ```
125
+
126
+ Make sure `NVIM_SOCKET` is set in the environment where OpenCode runs.
107
127
 
108
128
  If you use [CMUX](https://cmux.com), you can set this in your workspace configuration so both Neovim and OpenCode share the socket path automatically.
109
129
 
@@ -126,42 +146,60 @@ OpenCode (agent) Neovim (editor)
126
146
 
127
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.
128
148
 
129
- 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
130
170
 
131
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.
132
- - **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.
133
- - **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.
134
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.
135
- - **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.
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.
136
177
 
137
178
  ### Review workflow instructions
138
179
 
139
180
  The tool description embeds detailed workflow instructions for the AI agent:
140
181
 
141
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.
142
184
  - **No edits during review**: The agent is explicitly instructed to never edit files during the review. Feedback is collected and applied afterward.
143
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.
144
- - **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.
145
187
 
146
188
  ## Future improvements
147
189
 
148
190
  These were discussed during development but not yet implemented:
149
191
 
150
- ### Chunk-level navigation
192
+ ### Expand (surrounding context)
151
193
 
152
- 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.
153
-
154
- ### Logical ordering
155
-
156
- 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.
157
195
 
158
196
  ### Accept/reject per-hunk
159
197
 
160
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.
161
199
 
162
- ### Line range focus
200
+ ### Logical ordering
163
201
 
164
- 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.
165
203
 
166
204
  ### OpenCode session diff integration
167
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
 
@@ -68,6 +75,165 @@ function DiffviewState()
68
75
  })
69
76
  end
70
77
 
78
+ --- Get all diff hunks across all files as a flat array.
79
+ ---
80
+ --- Runs `git diff` and parses the output using diffview.nvim's diff parser.
81
+ --- Each hunk is a self-contained object with its file path, git status, and
82
+ --- line range information.
83
+ ---
84
+ --- @param ref string? Optional git ref to diff against (e.g. "HEAD~3").
85
+ --- Defaults to diffing uncommitted changes vs HEAD.
86
+ --- If diffview is open and has a rev_arg, that is used as
87
+ --- the default instead.
88
+ --- @return string JSON-encoded flat array of hunk objects:
89
+ --- [{ file, status, old_start, old_count, new_start, new_count, header }]
90
+ function DiffviewHunks(ref)
91
+ local ok, lib = pcall(require, "diffview.lib")
92
+ if not ok then
93
+ return vim.json.encode({ error = "diffview.nvim not loaded" })
94
+ end
95
+
96
+ local vcs_utils = require("diffview.vcs.utils")
97
+
98
+ -- Determine the git toplevel directory
99
+ local toplevel = vim.fn.systemlist("git rev-parse --show-toplevel")[1]
100
+ if vim.v.shell_error ~= 0 or not toplevel then
101
+ return vim.json.encode({ error = "Not in a git repository" })
102
+ end
103
+
104
+ -- If diffview is open, try to use its rev_arg as the default ref
105
+ if not ref then
106
+ local view = lib.get_current_view()
107
+ if view and view.rev_arg and view.rev_arg ~= "" then
108
+ ref = view.rev_arg
109
+ end
110
+ end
111
+
112
+ -- Get git status for each file (to include status letters in hunk data)
113
+ -- Use --name-status with the diff to get file statuses
114
+ local status_cmd = "git diff --name-status"
115
+ if ref then
116
+ status_cmd = status_cmd .. " " .. ref
117
+ end
118
+ local status_lines = vim.fn.systemlist(status_cmd)
119
+ local file_statuses = {}
120
+ for _, line in ipairs(status_lines) do
121
+ local status, path = line:match("^(%a)%s+(.+)$")
122
+ if status and path then
123
+ file_statuses[path] = status
124
+ end
125
+ end
126
+
127
+ -- Run git diff to get the full patch output
128
+ local diff_cmd = "git diff -U0"
129
+ if ref then
130
+ diff_cmd = diff_cmd .. " " .. ref
131
+ end
132
+ local diff_lines = vim.fn.systemlist(diff_cmd)
133
+ if vim.v.shell_error ~= 0 then
134
+ return vim.json.encode({ error = "git diff failed" })
135
+ end
136
+
137
+ if #diff_lines == 0 then
138
+ return vim.json.encode({})
139
+ end
140
+
141
+ -- Parse the diff using diffview's parser
142
+ local file_diffs = vcs_utils.parse_diff(diff_lines)
143
+
144
+ -- Flatten into a single array: one entry per hunk, each with its file info
145
+ local hunks = {}
146
+ for _, file_diff in ipairs(file_diffs) do
147
+ local path = file_diff.path_new or file_diff.path_old or ""
148
+ local status = file_statuses[path] or "M"
149
+
150
+ for _, hunk in ipairs(file_diff.hunks) do
151
+ local header = string.format(
152
+ "@@ -%d,%d +%d,%d @@",
153
+ hunk.old_row, hunk.old_size, hunk.new_row, hunk.new_size
154
+ )
155
+ table.insert(hunks, {
156
+ file = path,
157
+ status = status,
158
+ old_start = hunk.old_row,
159
+ old_count = hunk.old_size,
160
+ new_start = hunk.new_row,
161
+ new_count = hunk.new_size,
162
+ header = header,
163
+ })
164
+ end
165
+ end
166
+
167
+ return vim.json.encode(hunks)
168
+ end
169
+
170
+ --- Navigate diffview to a specific file and line number.
171
+ ---
172
+ --- Finds the file in diffview's file list, switches to it if needed,
173
+ --- then moves the cursor to the specified line in the right-hand (new) buffer.
174
+ ---
175
+ --- @param file string Repo-relative file path
176
+ --- @param line number Line number to jump to (in the new version of the file)
177
+ --- @return string JSON-encoded result: { ok: true } or { error: string }
178
+ function DiffviewGoTo(file, line)
179
+ local ok, lib = pcall(require, "diffview.lib")
180
+ if not ok then
181
+ return vim.json.encode({ error = "diffview.nvim not loaded" })
182
+ end
183
+
184
+ local view = lib.get_current_view()
185
+ if not view then
186
+ return vim.json.encode({ error = "diffview is not open" })
187
+ end
188
+
189
+ local utils = require("diffview.utils")
190
+ local panel = view.panel
191
+ local files = panel:ordered_file_list()
192
+
193
+ -- Find the target file in the file list
194
+ local target = nil
195
+ for _, f in ipairs(files) do
196
+ if f.path == file then
197
+ target = f
198
+ break
199
+ end
200
+ end
201
+
202
+ if not target then
203
+ return vim.json.encode({ error = "File not found in diffview: " .. file })
204
+ end
205
+
206
+ -- Switch to the target file if it's not already the current one
207
+ local cur_file = panel.cur_file
208
+ if not cur_file or cur_file.path ~= file then
209
+ view:set_file(target)
210
+ end
211
+
212
+ -- Move cursor to the target line in the right-hand (new version) buffer.
213
+ -- The layout has windows a (left/old) and b (right/new).
214
+ -- We need to wait briefly for the file switch to complete, then find
215
+ -- the right window and set the cursor.
216
+ vim.schedule(function()
217
+ -- Find the window showing the "b" (new) side of the diff
218
+ local layout = target.layout
219
+ if layout and layout.b and layout.b.file then
220
+ local b_win = layout.b.win
221
+ if b_win and vim.api.nvim_win_is_valid(b_win.id) then
222
+ -- Clamp line to buffer length
223
+ local bufnr = layout.b.file.bufnr
224
+ local max_line = vim.api.nvim_buf_line_count(bufnr)
225
+ local target_line = math.min(math.max(line or 1, 1), max_line)
226
+ vim.api.nvim_win_set_cursor(b_win.id, { target_line, 0 })
227
+ -- Center the view on the target line
228
+ vim.api.nvim_set_current_win(b_win.id)
229
+ vim.cmd("normal! zz")
230
+ end
231
+ end
232
+ end)
233
+
234
+ return vim.json.encode({ ok = true })
235
+ end
236
+
71
237
  --- Diffview hook: called when a diff view is opened.
72
238
  --- Snapshots the current buffer list so we know which buffers to clean up later.
73
239
  function M.on_view_opened(view)
@@ -103,7 +269,8 @@ function M.on_view_closed(view)
103
269
  end
104
270
 
105
271
  --- Set up the plugin. Call this from your plugin spec or init.lua.
106
- --- Configures diffview.nvim hooks for buffer cleanup.
272
+ --- Configures diffview.nvim hooks for buffer cleanup and registers global
273
+ --- functions for external tool access.
107
274
  ---
108
275
  --- Example with lazy.nvim:
109
276
  --- {
@@ -116,9 +283,11 @@ end
116
283
  function M.setup(opts)
117
284
  opts = opts or {}
118
285
 
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)
286
+ -- Register global functions (already defined at module load,
287
+ -- but this ensures they're available even if the module is lazy-loaded)
121
288
  _G.DiffviewState = DiffviewState
289
+ _G.DiffviewHunks = DiffviewHunks
290
+ _G.DiffviewGoTo = DiffviewGoTo
122
291
 
123
292
  -- Configure diffview hooks
124
293
  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,124 @@ 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
+
35
+ /**
36
+ * Discover a Neovim RPC socket when NVIM_SOCKET is not explicitly set.
37
+ *
38
+ * Strategy:
39
+ * 1. Check NVIM_SOCKET env var (always wins)
40
+ * 2. Scan for socket files in known locations
41
+ * 3. Verify each is live by attempting a connection
42
+ * 4. Prefer the Neovim instance whose cwd matches ours (same project)
43
+ * 5. Fall back to the first live socket found
44
+ */
45
+ const discoverNvimSocket = async (): Promise<string | null> => {
46
+ // 1. Explicit env var — skip discovery entirely
47
+ if (process.env.NVIM_SOCKET) return process.env.NVIM_SOCKET
48
+
49
+ // 2. Scan for socket files
50
+ const tmpdir = process.env.TMPDIR || "/tmp"
51
+ const user = process.env.USER || "unknown"
52
+ let socketPaths: string[] = []
53
+
54
+ try {
55
+ const output =
56
+ await Bun.$`find -L ${tmpdir}/nvim.${user} /tmp -maxdepth 4 -type s -name "nvim*" 2>/dev/null`.text()
57
+ socketPaths = output.trim().split("\n").filter(Boolean)
58
+ } catch {}
59
+
60
+ if (socketPaths.length === 0) return null
61
+
62
+ // 3 & 4. Check each socket — prefer cwd match, fall back to first live one
63
+ const ourCwd = process.cwd()
64
+ let fallback: string | null = null
65
+
66
+ for (const socketPath of socketPaths) {
67
+ try {
68
+ // Verify socket is live with a simple expression
69
+ await Bun.$`nvim --headless --server ${socketPath} --remote-expr "1+1"`.text()
70
+
71
+ // Try to get the PID from the socket filename (default sockets: nvim.<pid>.0)
72
+ let pid: string | undefined
73
+ const pidFromName = socketPath.match(/nvim\.(\d+)\.\d+$/)
74
+ if (pidFromName) {
75
+ pid = pidFromName[1]
76
+ } else {
77
+ // For --listen sockets (no PID in filename), find the owning process
78
+ try {
79
+ const lsof = await Bun.$`lsof ${socketPath} 2>/dev/null`.text()
80
+ const pidMatch = lsof.match(/nvim\s+(\d+)/)
81
+ if (pidMatch) pid = pidMatch[1]
82
+ } catch {}
83
+ }
84
+
85
+ // Get the cwd of the Neovim process and compare with ours
86
+ if (pid) {
87
+ try {
88
+ const lsof = await Bun.$`lsof -p ${pid} -Fn 2>/dev/null`.text()
89
+ const cwdMatch = lsof.match(/fcwd\nn(.+)/)
90
+ if (cwdMatch && cwdMatch[1] === ourCwd) {
91
+ return socketPath // Exact cwd match — this is our Neovim
92
+ }
93
+ } catch {}
94
+ }
95
+
96
+ // Remember the first live socket as fallback
97
+ if (!fallback) fallback = socketPath
98
+ } catch {
99
+ // Socket not responsive — stale socket from a crashed Neovim, skip it
100
+ }
101
+ }
102
+
103
+ return fallback
104
+ }
105
+
106
+ // --- Helpers ---
107
+
108
+ const statusLabel = (status: string | undefined): string =>
109
+ status === "M" ? "modified" :
110
+ status === "A" ? "added" :
111
+ status === "D" ? "deleted" :
112
+ status === "R" ? "renamed" :
113
+ status ?? "changed"
114
+
115
+ const formatHunkPosition = (): string => {
116
+ if (reviewQueue.length === 0) return "No review in progress."
117
+ const item = reviewQueue[reviewPosition]
118
+ return `Reviewing: ${item.file} (${statusLabel(item.status)}) ${item.header} — item ${reviewPosition + 1} of ${reviewQueue.length}.`
119
+ }
120
+
121
+ /**
122
+ * Match an order item from the agent to a hunk in the available hunks list.
123
+ * Identity is {file, old_start, new_start}.
124
+ */
125
+ const findHunk = (
126
+ hunks: HunkItem[],
127
+ orderItem: { file: string; old_start: number; old_count: number; new_start: number; new_count: number }
128
+ ): HunkItem | undefined =>
129
+ hunks.find(
130
+ h =>
131
+ h.file === orderItem.file &&
132
+ h.old_start === orderItem.old_start &&
133
+ h.old_count === orderItem.old_count &&
134
+ h.new_start === orderItem.new_start &&
135
+ h.new_count === orderItem.new_count
136
+ )
137
+
138
+ // --- Plugin ---
139
+
19
140
  export const DiffReviewPlugin: Plugin = async (ctx) => {
20
141
  return {
21
142
  tool: {
@@ -29,64 +150,90 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
29
150
  "- If you discover lint errors during review, close the diff, fix them, then restart\n\n" +
30
151
  "Workflow:\n" +
31
152
  "1. Fix any lint/format issues in your changes first\n" +
32
- "2. Call with action 'open' to show the diff (optionally scoped to specific files)\n" +
33
- " the response includes a list of ALL files that will be reviewed\n" +
34
- "3. Explain the changes visible in the current file (the response tells you which file is shown)\n" +
35
- "4. Ask the user if they have questions or feedback about these changes\n" +
36
- "5. If the user requests changes or leaves feedback on the current file, acknowledge it\n" +
37
- " and note it down but DO NOT make any changes yet. Continue the review.\n" +
38
- "6. Call with action 'next' to move to the next changed file\n" +
39
- " when you reach the last file, 'next' will tell you there are no more files\n" +
40
- "7. Repeat steps 3-6 for each file\n" +
41
- "8. Call with action 'close' when the review is complete\n" +
42
- "9. Propose a git commit message for the CURRENT changes and commit if the user approves\n" +
43
- "10. If the user left feedback or change requests during the review, NOW apply them\n" +
153
+ "2. Call with action 'get_hunks' to retrieve all change hunks across all files.\n" +
154
+ " Each hunk includes file path, status, and line range info.\n" +
155
+ "3. Analyze the hunks and decide a review order. Reorder them for narrative\n" +
156
+ " coherence — e.g. show the data model change before the API that uses it,\n" +
157
+ " then the UI that calls the API. For small changes, the natural order is fine.\n" +
158
+ " You may also filter out hunks that are trivial (e.g. import reordering).\n" +
159
+ "4. Call with action 'start_review' with the ordered hunks array to open the diff\n" +
160
+ " view and begin. If you omit the order, natural hunk order is used.\n" +
161
+ "5. Explain the current hunk shown in the diff view.\n" +
162
+ "6. Ask the user if they have questions or feedback about these changes.\n" +
163
+ "7. If the user requests changes or leaves feedback, acknowledge it and note it\n" +
164
+ " down but DO NOT make any changes yet. Continue the review.\n" +
165
+ "8. Call with action 'next' to advance to the next item in the review queue.\n" +
166
+ " When you reach the last item, 'next' will tell you there are no more items.\n" +
167
+ "9. Repeat steps 5-8 for each item\n" +
168
+ "10. Call with action 'close' when the review is complete\n" +
169
+ "11. Propose a git commit message for the CURRENT changes and commit if the user approves\n" +
170
+ "12. If the user left feedback or change requests during the review, NOW apply them\n" +
44
171
  " — this creates a clean separation: one commit for the original work,\n" +
45
172
  " a second commit for review feedback changes\n" +
46
- "11. If you made feedback changes, offer to walk through them with a second diff_review\n" +
173
+ "13. If you made feedback changes, offer to walk through them with a second diff_review\n" +
47
174
  " — since the original work is already committed, this diff will only show\n" +
48
175
  " the feedback changes, making them easy to verify\n\n" +
49
- "CRITICAL: During the review (steps 3-7), NEVER make changes to files.\n" +
176
+ "CRITICAL: During the review (steps 5-9), NEVER make changes to files.\n" +
50
177
  "Only collect feedback. Apply changes AFTER the review is closed and the\n" +
51
178
  "original work is committed.\n\n" +
52
- "Every response includes the current file path and position (e.g., '2 of 3') " +
179
+ "Every response includes the current item and position (e.g., 'item 2 of 5') " +
53
180
  "so you always know where you are in the review. Use the 'status' action " +
54
181
  "to re-orient if you lose track.",
55
182
  args: {
56
183
  action: tool.schema
57
- .enum(["open", "next", "prev", "close", "status"])
184
+ .enum(["get_hunks", "start_review", "next", "prev", "status", "close"])
58
185
  .describe(
59
- "open: show diff view in Neovim. " +
60
- "next: navigate to next changed file. " +
61
- "prev: navigate to previous changed file. " +
62
- "close: close the diff view. " +
63
- "status: get current file and position without navigating."
186
+ "get_hunks: retrieve all diff hunks across all files as a flat array. " +
187
+ "start_review: open the diff view and begin reviewing, optionally with a custom order. " +
188
+ "next: navigate to the next item in the review queue. " +
189
+ "prev: navigate to the previous item in the review queue. " +
190
+ "status: get current position in the review queue without navigating. " +
191
+ "close: close the diff view and end the review."
192
+ ),
193
+ ref: tool.schema
194
+ .string()
195
+ .optional()
196
+ .describe(
197
+ "Git ref to diff against (get_hunks and start_review only). " +
198
+ "Defaults to showing uncommitted changes vs HEAD. " +
199
+ "Examples: HEAD~3, a commit hash, origin/main"
64
200
  ),
65
201
  files: tool.schema
66
202
  .array(tool.schema.string())
67
203
  .optional()
68
204
  .describe(
69
- "File paths to include in the diff (open only). " +
70
- "Omit to show all uncommitted changes."
205
+ "File paths to include in the diff (get_hunks and start_review only). " +
206
+ "Omit to include all uncommitted changes."
71
207
  ),
72
- ref: tool.schema
73
- .string()
208
+ order: tool.schema
209
+ .array(
210
+ tool.schema.object({
211
+ file: tool.schema.string().describe("Repo-relative file path"),
212
+ old_start: tool.schema.number().describe("Start line in old version"),
213
+ old_count: tool.schema.number().describe("Line count in old version"),
214
+ new_start: tool.schema.number().describe("Start line in new version"),
215
+ new_count: tool.schema.number().describe("Line count in new version"),
216
+ })
217
+ )
74
218
  .optional()
75
219
  .describe(
76
- "Git ref to diff against (open only). " +
77
- "Defaults to showing uncommitted changes vs HEAD. " +
78
- "Examples: HEAD~3, a commit hash, origin/main"
220
+ "Custom review order (start_review only). Array of hunk identifiers " +
221
+ "from the get_hunks response, in the order you want to review them. " +
222
+ "Each item needs: file, old_start, old_count, new_start, new_count. " +
223
+ "Omit to use the natural hunk order."
79
224
  ),
80
225
  },
81
226
  async execute(args, context) {
82
- const socket = process.env.NVIM_SOCKET
227
+ const socket = await discoverNvimSocket()
83
228
  if (!socket) {
84
- return "NVIM_SOCKET environment variable is not set. " +
85
- "Make sure Neovim is running with --listen and NVIM_SOCKET is exported.\n\n" +
229
+ return "Could not find a running Neovim instance.\n\n" +
230
+ "The tool looks for Neovim in this order:\n" +
231
+ "1. NVIM_SOCKET environment variable (if set)\n" +
232
+ "2. Neovim instances whose working directory matches this project\n" +
233
+ "3. Any running Neovim instance\n\n" +
86
234
  "Quick setup:\n" +
87
235
  " export NVIM_SOCKET=/tmp/nvim.sock\n" +
88
- " nvim --listen $NVIM_SOCKET\n\n" +
89
- "If using CMUX, the workspace command sets this automatically."
236
+ " nvim --listen $NVIM_SOCKET"
90
237
  }
91
238
 
92
239
  const nvimExpr = (expr: string) =>
@@ -101,117 +248,172 @@ export const DiffReviewPlugin: Plugin = async (ctx) => {
101
248
  }
102
249
  }
103
250
 
104
- const statusLabel = (status: string | undefined): string =>
105
- status === "M" ? "modified" :
106
- status === "A" ? "added" :
107
- status === "D" ? "deleted" :
108
- status === "R" ? "renamed" :
109
- status ?? "changed"
110
-
111
- const formatState = (state: DiffviewState): string => {
112
- if (!state.open) return "Diff view is not open."
113
- if (!state.current_file)
114
- return `Diff view is open but no files to show (${state.total ?? 0} files total).`
115
- return `Currently showing: ${state.current_file} (${statusLabel(state.status)}) — file ${state.index} of ${state.total}.`
251
+ const getHunks = async (ref?: string): Promise<HunkItem[]> => {
252
+ const luaArg = ref ? `"${ref.replace(/"/g, '\\"')}"` : ""
253
+ const raw = await nvimExpr(`luaeval("DiffviewHunks(${luaArg})")`)
254
+ const parsed = JSON.parse(raw.trim())
255
+ if (parsed.error) throw new Error(parsed.error)
256
+ return parsed as HunkItem[]
116
257
  }
117
258
 
118
- const formatFileList = (state: DiffviewState): string => {
119
- if (!state.files || state.files.length === 0) return ""
120
- const list = state.files
121
- .map((f, i) => ` ${i + 1}. ${f.path} (${statusLabel(f.status)})`)
122
- .join("\n")
123
- return `\nFiles to review:\n${list}`
259
+ const goToHunk = async (item: HunkItem): Promise<void> => {
260
+ const file = item.file.replace(/"/g, '\\"')
261
+ // Navigate to the new_start line (the line in the new version)
262
+ const line = item.new_start > 0 ? item.new_start : 1
263
+ const raw = await nvimExpr(
264
+ `luaeval("DiffviewGoTo('${file}', ${line})")`
265
+ )
266
+ const result = JSON.parse(raw.trim())
267
+ if (result.error) throw new Error(result.error)
268
+ // Give diffview time to switch files and position the cursor
269
+ await Bun.sleep(300)
124
270
  }
125
271
 
126
272
  try {
127
273
  switch (args.action) {
128
- case "open": {
274
+ case "get_hunks": {
275
+ // Store ref/files for later use by start_review
276
+ reviewRef = args.ref
277
+ reviewFiles = args.files
278
+
279
+ const hunks = await getHunks(args.ref)
280
+
281
+ if (hunks.length === 0) {
282
+ return "No changes found." +
283
+ (args.ref ? ` (compared against ${args.ref})` : "")
284
+ }
285
+
286
+ // Summarize: count files and hunks
287
+ const fileSet = new Set(hunks.map(h => h.file))
288
+ const summary = `Found ${hunks.length} hunk${hunks.length === 1 ? "" : "s"} ` +
289
+ `across ${fileSet.size} file${fileSet.size === 1 ? "" : "s"}` +
290
+ (args.ref ? ` (compared against ${args.ref})` : "") + ".\n\n"
291
+
292
+ return summary + JSON.stringify(hunks, null, 2)
293
+ }
294
+
295
+ case "start_review": {
296
+ // Use ref/files from get_hunks if not explicitly provided
297
+ const ref = args.ref ?? reviewRef
298
+ const files = args.files ?? reviewFiles
299
+
300
+ // Open diffview
129
301
  let cmd = "DiffviewOpen"
130
- if (args.ref) {
131
- cmd += ` ${args.ref}`
302
+ if (ref) {
303
+ cmd += ` ${ref}`
132
304
  }
133
- if (args.files && args.files.length > 0) {
134
- const escaped = args.files.map(f => f.replace(/ /g, "\\ ")).join(" ")
305
+ if (files && files.length > 0) {
306
+ const escaped = files.map(f => f.replace(/ /g, "\\ ")).join(" ")
135
307
  cmd += ` -- ${escaped}`
136
308
  }
137
309
  await nvimExpr(`luaeval("vim.cmd('${cmd.replace(/'/g, "''")}')")`)
138
- // Give diffview a moment to populate the file list
310
+ // Give diffview time to populate the file list
139
311
  await Bun.sleep(500)
140
- const state = await getState()
141
- return `Opened diff view in Neovim` +
142
- (args.ref ? ` (comparing against ${args.ref})` : " (uncommitted changes vs HEAD)") +
143
- `. ${formatState(state)}` +
144
- formatFileList(state)
312
+
313
+ // Build the review queue
314
+ if (args.order && args.order.length > 0) {
315
+ // Agent provided a custom order — resolve each item to a full hunk
316
+ const allHunks = await getHunks(ref)
317
+ const queue: HunkItem[] = []
318
+ const unmatched: string[] = []
319
+
320
+ for (const orderItem of args.order) {
321
+ const match = findHunk(allHunks, orderItem)
322
+ if (match) {
323
+ queue.push(match)
324
+ } else {
325
+ unmatched.push(`${orderItem.file} ${orderItem.old_start}→${orderItem.new_start}`)
326
+ }
327
+ }
328
+
329
+ if (queue.length === 0) {
330
+ return "Could not match any items in the provided order to actual hunks. " +
331
+ `Unmatched: ${unmatched.join(", ")}. ` +
332
+ "Call 'get_hunks' to see available hunks."
333
+ }
334
+
335
+ reviewQueue = queue
336
+
337
+ if (unmatched.length > 0) {
338
+ // Warn but proceed with what we have
339
+ }
340
+ } else {
341
+ // No custom order — use natural hunk order
342
+ reviewQueue = await getHunks(ref)
343
+
344
+ if (reviewQueue.length === 0) {
345
+ return "Opened diff view but no hunks found." +
346
+ (ref ? ` (compared against ${ref})` : "")
347
+ }
348
+ }
349
+
350
+ // Navigate to the first item
351
+ reviewPosition = 0
352
+ await goToHunk(reviewQueue[0])
353
+
354
+ return `Started review with ${reviewQueue.length} item${reviewQueue.length === 1 ? "" : "s"}` +
355
+ (ref ? ` (comparing against ${ref})` : " (uncommitted changes vs HEAD)") +
356
+ `. ${formatHunkPosition()}`
145
357
  }
146
358
 
147
359
  case "next": {
148
- const before = await getState()
149
- if (!before.open) return "Diff view is not open. Call with action 'open' first."
150
-
151
- // If already on the last file, don't navigate (diffview wraps around)
152
- if (before.index !== undefined && before.total !== undefined &&
153
- before.index >= before.total) {
154
- return `Already at the last file (file ${before.index} of ${before.total}). ` +
155
- `${formatState(before)} There are no more files to review. ` +
156
- "Use action 'close' to end the review."
360
+ if (reviewQueue.length === 0) {
361
+ return "No review in progress. Call 'start_review' first."
157
362
  }
158
363
 
159
- await nvimExpr(`luaeval("require('diffview').emit('select_next_entry')")`)
160
- await Bun.sleep(200)
161
- const after = await getState()
162
-
163
- // Detect wrap-around: if index went down, diffview wrapped to the beginning
164
- if (before.index !== undefined && after.index !== undefined &&
165
- after.index < before.index) {
166
- // Undo the wrap by going back
167
- await nvimExpr(`luaeval("require('diffview').emit('select_prev_entry')")`)
168
- await Bun.sleep(200)
169
- const restored = await getState()
170
- return `Already at the last file (file ${restored.index} of ${restored.total}). ` +
171
- `${formatState(restored)} There are no more files to review. ` +
364
+ if (reviewPosition >= reviewQueue.length - 1) {
365
+ return `Already at the last item (item ${reviewPosition + 1} of ${reviewQueue.length}). ` +
366
+ `${formatHunkPosition()} There are no more items to review. ` +
172
367
  "Use action 'close' to end the review."
173
368
  }
174
369
 
175
- return `Navigated to next file. ${formatState(after)}`
370
+ reviewPosition++
371
+ await goToHunk(reviewQueue[reviewPosition])
372
+
373
+ return `Navigated to next item. ${formatHunkPosition()}`
176
374
  }
177
375
 
178
376
  case "prev": {
179
- const before = await getState()
180
- if (!before.open) return "Diff view is not open. Call with action 'open' first."
181
-
182
- // If already on the first file, don't navigate (diffview wraps around)
183
- if (before.index !== undefined && before.index <= 1) {
184
- return `Already at the first file (file ${before.index} of ${before.total}). ` +
185
- `${formatState(before)} There are no previous files.`
377
+ if (reviewQueue.length === 0) {
378
+ return "No review in progress. Call 'start_review' first."
186
379
  }
187
380
 
188
- await nvimExpr(`luaeval("require('diffview').emit('select_prev_entry')")`)
189
- await Bun.sleep(200)
190
- const after = await getState()
191
-
192
- // Detect wrap-around: if index went up, diffview wrapped to the end
193
- if (before.index !== undefined && after.index !== undefined &&
194
- after.index > before.index) {
195
- // Undo the wrap by going forward
196
- await nvimExpr(`luaeval("require('diffview').emit('select_next_entry')")`)
197
- await Bun.sleep(200)
198
- const restored = await getState()
199
- return `Already at the first file (file ${restored.index} of ${restored.total}). ` +
200
- `${formatState(restored)} There are no previous files.`
381
+ if (reviewPosition <= 0) {
382
+ return `Already at the first item (item ${reviewPosition + 1} of ${reviewQueue.length}). ` +
383
+ `${formatHunkPosition()} There are no previous items.`
201
384
  }
202
385
 
203
- return `Navigated to previous file. ${formatState(after)}`
386
+ reviewPosition--
387
+ await goToHunk(reviewQueue[reviewPosition])
388
+
389
+ return `Navigated to previous item. ${formatHunkPosition()}`
204
390
  }
205
391
 
206
392
  case "status": {
207
393
  const state = await getState()
208
- if (!state.open) return "Diff view is not currently open."
209
- return `${formatState(state)}${formatFileList(state)}`
394
+ if (!state.open && reviewQueue.length === 0) {
395
+ return "No review in progress and diff view is not open."
396
+ }
397
+
398
+ if (reviewQueue.length === 0) {
399
+ return "Diff view is open but no review queue. Call 'start_review' to begin."
400
+ }
401
+
402
+ return formatHunkPosition()
210
403
  }
211
404
 
212
405
  case "close": {
213
406
  await nvimExpr(`luaeval("require('diffview').close()")`)
214
- return "Closed the diff view in Neovim."
407
+
408
+ // Clear review state
409
+ const itemCount = reviewQueue.length
410
+ reviewQueue = []
411
+ reviewPosition = -1
412
+ reviewRef = undefined
413
+ reviewFiles = undefined
414
+
415
+ return `Closed the diff view and ended the review` +
416
+ (itemCount > 0 ? ` (reviewed ${itemCount} item${itemCount === 1 ? "" : "s"}).` : ".")
215
417
  }
216
418
  }
217
419
  } catch (e: any) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-nvim-diff-review",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Agent-driven guided code review for Neovim + OpenCode",
5
5
  "main": "opencode-plugin/index.ts",
6
6
  "keywords": [