Review the working diff in Neovim beside Claude Code, or the last response in a pane: annotate lines, ask questions inline and prepare a follow-up prompt

Review code changes in Neovim beside Claude Code, or Claude's last response in a pane. Attach comments, suggestions and issues to lines or ranges, ask questions that are answered where you asked them, and turn the review into a prompt you edit and send yourself. Nothing is submitted automatically.
A port of pi-comment-editor to Claude Code, extended from responses to diffs.
/plugin install review-pane --marketplace ramtinJ95/claude-mods
/review-diff needs Neovim 0.10 or newer on PATH.
/review-diff opens the uncommitted changes against HEAD in Neovim, untracked files included (ignored files are not). /review-diff base opens everything since the merge-base with the default branch (origin/HEAD, else origin/main, origin/master, main or master)./review-response opens Claude's last response in a pane, line by line./review-diff opens Neovim in a split to the right of Claude Code: in Herdr when it runs there, else in tmux, else in kitty with remote control enabled. Anywhere else it prints a sh …/open.sh command to run in a terminal of your own. Neovim starts with your own config; the mod adds its Lua module and nothing else.
You review the real files, not a diff. The quickfix list holds every run of changes (:cnext, :cprev, :Review files brings it back), and with gitsigns.nvim the changes are marked against the same base. Annotations are tied to lines and follow them as you edit. Lines a change removed are not in the file; annotate the line beside them.
| Command | Key | Action |
|---|---|---|
:Review comment | <leader>rc | Comment on the line or visual range |
:Review suggest | <leader>rs | Suggestion on the line or range |
:Review issue | <leader>ri | Issue on the line or range |
:Review ask | <leader>ra | Ask a question about the line or range |
:Review overall | <leader>ro | Comment on the changes as a whole (one; empty removes it) |
:Review edit / delete | <leader>re / <leader>rx | Edit or delete the annotation under the cursor |
:Review show | <leader>rv | Read the annotation and its answer in full |
:Review next / prev | ]r / [r | Next or previous annotation |
:Review list | <leader>rl | All annotations in the location list |
:Review files | <leader>rf | The changes in the quickfix list |
:Review finish | <leader>ry | Put the review in Claude Code's prompt and close Neovim |
:Review discard | <leader>rq | Drop the annotations and close Neovim |
A key your config already maps is left alone; :Review always works. Quitting Neovim keeps the annotations: /review-diff reopens them, against either base.
In fullscreen the pane docks beside the transcript; otherwise it opens above the prompt. Running /review-response again while the review has annotations or an open draft resumes it; with no work in it, the command reloads the latest response.
Responses, annotations and answers render their markdown (emphasis, inline code, headings, lists, quotes, tables, fenced code highlighted by its language) one source line per row, so line numbers still match what you annotate. All colours come from your Claude Code theme.
The pane takes the keyboard when it opens. Esc hands the keys back to the prompt and keeps the review; run the command again, or click the pane, to continue. Keys that do not apply right now are drawn dim and say why when pressed.
| Key | Action |
|---|---|
j / k | Next / previous line |
d / u | Half a page down / up |
g / b | First / last line |
v | Start or end a range |
c / s / i | Comment, suggestion or issue on the line or range |
a | Ask a question about the line or range |
o | Comment on the response as a whole |
h / l | Previous / next annotation |
r / x | Edit / delete the annotation under the cursor |
e | Read the annotation in full: j/k/d/u scroll it, e returns to the response |
y | Finish: put the review in the prompt |
q | Close; with annotations, y confirms the discard and n keeps them |
Annotations are typed on one line: Enter saves, and an empty Enter cancels. A half-typed annotation survives Esc and comes back when you resume. Hotkeys are lowercase letters only, because Claude Code passes only those to a pane without a click.
A question is answered by a fork of the conversation ($.model.fork): the same model and context the main thread has, but no tools, and nothing is added to the conversation. From Neovim it carries the lines asked about and the ten lines around them as the file stands; in the pane, the lines of the response. In a session with no response yet there is nothing to fork, so the answer comes from the excerpt alone and says so.
Answers appear under the question (in Neovim the first lines; :Review show reads the rest). Questions are left out of the prepared prompt. Editing a question asks it again; an answer to the old text is dropped.
Finishing puts the review into the prompt, after any text already there:
I reviewed the uncommitted changes. Please address these annotations.
1. **[ISSUE]** `src/cache.ts` lines 12-14
if (!response.ok) return cachedValue; await save(response.body); return response.body;
This silently returns stale data for every server error.
Overall comments come first, then line annotations in file order. Each one quotes the lines it targets as they stand when you finish.
hooks/register.tsx 652 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AnnotationKind, NvimReview, ReviewDoc, ReviewView } from '../types'
5import { DIFF_FLAGS, GIT_CONFIG, parseDiff } from './diff'
6import type { DiffRow } from './diff'
7import { describeTarget, formatFileQuestion, formatFileReview, formatQuestion, formatReview } from './format'
8import { halfPage, layoutRows, splitLines, visibleOffset } from './layout'
9import type { Layout } from './layout'
10import { changeStops, luaCall, openScript, parseEvent, shellQuote } from './nvim'
11import type { AskEvent, FileNote } from './nvim'
12import { hangAt, lookText, markdownLooks, plainLook, sliceLook, wrapLook } from './style'
13import type { Look } from './style'
14import {
15 KINDS,
16 activeAnnotation,
17 annotationsAtRow,
18 beginDraft,
19 clearNotice,
20 createView,
21 currentTarget,
22 deleteAnnotation,
23 editAnnotation,
24 hasWork,
25 moveTo,
26 navigateAnnotation,
27 saveDraft,
28 scrollPreview,
29 setDraftBody,
30 setReply,
31 sortAnnotations,
32 toggleReading,
33 toggleSelection,
34} from './model'
35
36const PANE = 'review-pane'
37const DRAFT = 'draft'
38const PRIORITY = Object.keys(KINDS) as AnnotationKind[]
39const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
40
41const DOC = { plugin: 'review-pane', key: 'doc' } as const
42const VIEW = { plugin: 'review-pane', key: 'view' } as const
43const NVIM = { plugin: 'review-pane', key: 'nvim' } as const
44const viewAtom = atom(VIEW, null)
45const nvimAtom = atom(NVIM, null)
46
47type Base = 'head' | 'merge-base'
48type Diff = { root: string; title: string; base?: string; files: string[]; rows: DiffRow[] }
49
50const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
51
52async function git($: EngineInterface, args: string[], init: { cwd?: string; env?: Record<string, string> } = {}) {
53 return $.process.run(['git', ...GIT_CONFIG, ...args], { ...init, timeoutMs: 30_000 })
54}
55
56async function updateView($: EngineInterface, fn: (view: ReviewView) => ReviewView) {
57 await update($, viewAtom, view => view && fn(view))
58}
59
60async function notify($: EngineInterface, notice: string) {
61 await updateView($, view => ({ ...view, notice }))
62}
63
64async function defaultBranch($: EngineInterface, root: string): Promise<string | undefined> {
65 const remote = await git($, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd: root })
66 if (remote.exitCode === 0 && remote.stdout.trim()) return remote.stdout.trim()
67 for (const candidate of ['origin/main', 'origin/master', 'main', 'master']) {
68 const found = await git($, ['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], { cwd: root })
69 if (found.exitCode === 0) return candidate
70 }
71 return undefined
72}
73
74/** The working tree against `base`, untracked files included; a string says why there is none. */
75async function loadDiff($: EngineInterface, base: Base): Promise<Diff | string> {
76 const top = await git($, ['rev-parse', '--show-toplevel'])
77 if (top.exitCode !== 0) return 'Not inside a git repository.'
78 const root = top.stdout.trim()
79
80 const head = await git($, ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: root })
81 const hasHead = head.exitCode === 0
82 let from = hasHead ? 'HEAD' : EMPTY_TREE
83 let title = 'uncommitted changes'
84 if (base === 'merge-base') {
85 const branch = await defaultBranch($, root)
86 if (!branch) return 'No default branch found (origin/HEAD, main or master) to diff against.'
87 const mergeBase = await git($, ['merge-base', 'HEAD', branch], { cwd: root })
88 if (mergeBase.exitCode !== 0) return `No merge-base between HEAD and ${branch}.`
89 from = mergeBase.stdout.trim()
90 title = `changes since ${branch}`
91 }
92
93 // A throwaway copy of the index with every untracked file added as intent-to-add, so one
94 // `git diff` shows new files too (staged ones, even ignored, stay) and the person's own index
95 // is never touched.
96 const located = await git($, ['rev-parse', '--path-format=absolute', '--git-path', 'index'], { cwd: root })
97 if (located.exitCode !== 0) return `Could not locate the index: ${located.stderr.trim()}`
98 const index = located.stdout.trim()
99 const temp = await $.process.run(['mktemp', '-d'])
100 const dir = temp.stdout.trim()
101 if (temp.exitCode !== 0 || !dir.startsWith('/')) return `Could not create a temporary index: ${temp.stderr.trim()}`
102 try {
103 const env = { GIT_INDEX_FILE: `${dir}/index` }
104 // A repository with no index file yet has an empty one, which is what the copy then starts from.
105 if ((await $.process.run(['test', '-e', index])).exitCode === 0) {
106 const copied = await $.process.run(['cp', index, env.GIT_INDEX_FILE])
107 if (copied.exitCode !== 0) return `Could not copy the index: ${copied.stderr.trim()}`
108 }
109 const added = await git($, ['add', '--intent-to-add', '--all', '.'], { cwd: root, env })
110 if (added.exitCode !== 0) return `git add failed: ${added.stderr.trim()}`
111 const diff = await git($, ['diff', ...DIFF_FLAGS, from, '--'], { cwd: root, env })
112 if (diff.exitCode !== 0) return `git diff failed: ${diff.stderr.trim()}`
113 if (diff.isStdoutTruncated) return 'The diff is larger than 4 MiB; review it in smaller pieces.'
114 // Gitsigns cannot compare against the empty tree; a repository with no commit gets no base.
115 return { root, title, base: hasHead ? from : undefined, ...parseDiff(diff.stdout) }
116 } finally {
117 await $.process.run(['rm', '-rf', dir])
118 }
119}
120
121async function loadResponse($: EngineInterface): Promise<ReviewDoc | string> {
122 const messages = await $.session.messages()
123 const last = [...messages].reverse().find(message => message.role === 'assistant' && message.text.trim())
124 if (!last) return 'No assistant response to review yet.'
125 return { rows: splitLines(last.text.trim()).map(text => ({ text })) }
126}
127
128async function openPane($: EngineInterface): Promise<string> {
129 const opened = await $.ui.open({ id: PANE, title: 'Review', focus: true, rows: 40, columns: 100 })
130 return opened.isPlaced ? '' : ` The pane is not shown: ${'reason' in opened ? opened.reason : 'no room'}.`
131}
132
133async function startReview($: EngineInterface): Promise<string> {
134 const [{ value: doc }, before] = await Promise.all([$.state.get(DOC), $.state.get(VIEW)])
135 const view = before.value
136 if (doc && view && hasWork(view)) return `Resumed the open review.${await openPane($)}`
137 const loaded = await loadResponse($)
138 if (typeof loaded === 'string') return loaded
139 // Puts the review in place only over the view still at its version: a note added while it loaded must not be wiped.
140 const swapped = await $.state.set(VIEW, createView(), { ifVersion: before.version })
141 if (!swapped.isSet) return 'The review changed while it loaded, so it was kept: run the command again to resume it.'
142 await $.state.set(DOC, loaded)
143 return `Opened a review of the previous response.${await openPane($)}`
144}
145
146async function endReview($: EngineInterface) {
147 await $.state.set(VIEW, null)
148 await $.state.set(DOC, null)
149 await $.ui.close({ id: PANE })
150}
151
152/** Asks a question of a fork of the conversation, or of the session model before there is one to fork. */
153async function answerQuestion($: EngineInterface, prompt: string): Promise<{ text: string } | { error: string }> {
154 const forked = await $.model.fork({ prompt })
155 // Before the first response there is no conversation to fork: answer from the excerpt alone, and say so.
156 const isAlone = !forked.isAnswered && forked.reason === 'nothing-to-fork'
157 const result = isAlone ? await $.model.complete({ model: await $.session.model(), prompt }) : forked
158 if (result.isAnswered) return { text: isAlone ? `(No conversation yet: answered from the excerpt alone.)\n${result.text}` : result.text }
159 return { error: `No answer (${result.reason}${'status' in result && result.status ? ` ${result.status}` : ''}).` }
160}
161
162async function askQuestion($: EngineInterface, doc: ReviewDoc, question: ReviewView['annotations'][number]) {
163 const token = crypto.randomUUID()
164 await updateView($, view => setReply(view, question.id, { state: 'pending', token }))
165 const answer = await answerQuestion($, formatQuestion(doc, question))
166 const reply = 'text' in answer ? { state: 'answered' as const, text: answer.text } : { state: 'failed' as const, error: answer.error }
167 await updateView($, view => setReply(view, question.id, reply, token))
168}
169
170async function submitDraft($: EngineInterface, doc: ReviewDoc, body: string) {
171 let saved: ReviewView['annotations'][number] | undefined
172 await updateView($, view => {
173 const result = saveDraft(view, body)
174 saved = result.saved
175 return result.view
176 })
177 if (saved?.kind === 'question') await askQuestion($, doc, saved)
178}
179
180/** Puts `text` in the prompt, after what is typed there; a string says why it could not. */
181async function fillPrompt($: EngineInterface, text: string): Promise<string | undefined> {
182 const box = await $.prompt.read()
183 const filled = await $.prompt.fill(box.text.trim() ? { text: `\n\n${text}`, mode: 'append' } : { text })
184 if (filled.isFilled) return undefined
185 const why = filled.refusal === 'dialog' ? 'a dialog is open' : filled.refusal === 'no_composer' ? 'there is no prompt box' : 'another plugin refused it'
186 return `Could not fill the prompt: ${why}.`
187}
188
189const FILLED = 'Review loaded into the prompt. Edit it, then send it yourself.'
190
191async function finishReview($: EngineInterface) {
192 const [{ value: doc }, { value: view }] = await Promise.all([$.state.get(DOC), $.state.get(VIEW)])
193 if (!doc || !view) return
194 const text = formatReview(doc, view.annotations)
195 if (!text) {
196 await notify($, 'Nothing to send: add a comment, suggestion or issue (questions stay here).')
197 return
198 }
199 const problem = await fillPrompt($, text)
200 if (problem) {
201 await notify($, problem)
202 return
203 }
204 await endReview($)
205 await $.ui.toast(FILLED)
206}
207
208/** Calls the review's Neovim over its socket. */
209async function remote($: EngineInterface, dir: string, expr: string) {
210 return $.process.run(['nvim', '--server', `${dir}/nvim.sock`, '--remote-expr', expr], { timeoutMs: 5_000 })
211}
212
213async function isAlive($: EngineInterface, dir: string): Promise<boolean> {
214 const answer = await remote($, dir, '1')
215 return answer.exitCode === 0 && answer.stdout.trim() === '1'
216}
217
218async function callNvim($: EngineInterface, dir: string, fn: 'answer' | 'finished', payload: unknown) {
219 const result = await remote($, dir, luaCall(fn, payload))
220 if (result.exitCode !== 0) await $.ui.toast(`review-pane: Neovim did not take the ${fn === 'answer' ? 'answer' : 'result'} (${result.stderr.trim() || `exit ${result.exitCode}`}).`)
221}
222
223async function answerInNvim($: EngineInterface, review: NvimReview, ask: AskEvent) {
224 const answer = await answerQuestion($, formatFileQuestion(review.title, ask))
225 await callNvim($, review.dir, 'answer', { id: ask.id, token: ask.token, ...answer })
226}
227
228async function finishInNvim($: EngineInterface, review: NvimReview, notes: FileNote[]) {
229 const text = formatFileReview(review.title, notes)
230 const problem = text ? await fillPrompt($, text) : 'Nothing to send.'
231 await callNvim($, review.dir, 'finished', problem ? { ok: false, error: problem } : { ok: true })
232 if (!problem) await $.ui.toast(FILLED)
233}
234
235/** Neovim quit: its annotations wait in the review's directory for /review-diff, or with none the review ends. */
236async function closedInNvim($: EngineInterface, review: NvimReview, count: number) {
237 if (count > 0) {
238 await update($, nvimAtom, current => current && current.dir === review.dir ? { ...current, isOpen: false } : current)
239 await $.ui.toast(`Neovim closed with ${plural(count, 'annotation')}: /review-diff reopens them.`)
240 return
241 }
242 await update($, nvimAtom, current => current && current.dir === review.dir ? null : current)
243 await $.process.run(['rm', '-rf', review.dir])
244}
245
246/** Bumped by every follower started: an older one stops at its next piece instead of handling it. */
247let generation = 0
248
249/**
250 * Follows the events Neovim appends, from the offset already handled. The loop is the `tail`'s
251 * life: it ends when Neovim closes, when a newer follower starts, or with the module.
252 */
253function follow($: EngineInterface, review: NvimReview) {
254 const mine = ++generation
255 void (async () => {
256 const tail = $.process.spawn({ argv: ['tail', '-c', `+${review.offset + 1}`, '-F', `${review.dir}/events.jsonl`] })
257 let pending = ''
258 let offset = review.offset
259 try {
260 for await (const { stream, text } of tail) {
261 if (mine !== generation) return
262 // tail -F says on stderr that the file is not there yet.
263 if (stream !== 'stdout') continue
264 pending += text
265 const lines = pending.split('\n')
266 pending = lines.pop()!
267 for (const line of lines) {
268 offset += new TextEncoder().encode(line).length + 1
269 await update($, nvimAtom, current => current && current.dir === review.dir ? { ...current, offset } : current)
270 if (!line.trim()) continue
271 const event = parseEvent(line)
272 if (!event) await $.ui.toast(`review-pane: unreadable message from Neovim: ${line.slice(0, 80)}`)
273 // A question waits on the model, so it does not hold up the events after it.
274 else if (event.type === 'ask') void answerInNvim($, review, event)
275 else if (event.type === 'finish') await finishInNvim($, review, event.annotations)
276 else return await closedInNvim($, review, event.count)
277 }
278 }
279 } catch (error) {
280 await $.ui.toast(`review-pane stopped listening to Neovim: ${error instanceof Error ? error.message : String(error)}`)
281 }
282 })()
283}
284
285/** Opens `script` beside Claude Code: in Herdr, tmux or kitty, else says how to run it. */
286async function launch($: EngineInterface, root: string, script: string): Promise<string> {
287 const listed = await $.process.run(['env'])
288 const env = Object.fromEntries(listed.stdout.split('\n').flatMap(line => {
289 const at = line.indexOf('=')
290 return at > 0 ? [[line.slice(0, at), line.slice(at + 1)]] : []
291 }))
292 const manual = `Run \`sh ${shellQuote(script)}\` in a terminal to open the review in Neovim.`
293 const opened = 'Opened the review in Neovim beside this pane.'
294 const failed = (who: string, result: { exitCode: number; stderr: string }) =>
295 `${who} could not open a pane (${result.stderr.trim() || `exit ${result.exitCode}`}). ${manual}`
296 if (env.HERDR_ENV === '1' && env.HERDR_PANE_ID) {
297 const split = await $.process.run(['herdr', 'pane', 'split', env.HERDR_PANE_ID, '--direction', 'right', '--focus', '--cwd', root])
298 let pane: unknown
299 try {
300 pane = JSON.parse(split.stdout)?.result?.pane?.pane_id
301 } catch {
302 pane = undefined
303 }
304 if (typeof pane !== 'string') return failed('Herdr', split)
305 // `exec`, so the pane closes with Neovim.
306 const ran = await $.process.run(['herdr', 'pane', 'run', pane, `exec sh ${shellQuote(script)}`])
307 return ran.exitCode === 0 ? opened : failed('Herdr', ran)
308 }
309 if (env.TMUX) {
310 const split = await $.process.run(['tmux', 'split-window', '-h', ...(env.TMUX_PANE ? ['-t', env.TMUX_PANE] : []), '-c', root, 'sh', script])
311 return split.exitCode === 0 ? opened : failed('tmux', split)
312 }
313 if (env.KITTY_WINDOW_ID) {
314 const split = await $.process.run(['kitten', '@', 'launch', '--location=vsplit', '--cwd', root, 'sh', script], { timeoutMs: 10_000 })
315 return split.exitCode === 0 ? opened : failed('kitty', split)
316 }
317 return manual
318}
319
320async function reviewInNvim($: EngineInterface, base: Base): Promise<string> {
321 if ((await $.process.run(['sh', '-c', 'command -v nvim'])).exitCode !== 0) return '/review-diff opens the diff in Neovim, and nvim is not on PATH.'
322 const existing = (await $.state.get(NVIM)).value
323 if (existing?.isOpen && (await isAlive($, existing.dir))) return 'The review is already open in Neovim.'
324 const loaded = await loadDiff($, base)
325 if (typeof loaded === 'string') return loaded
326 // The same repository's review reopens with its annotations, which are keyed by file and line, so the base may
327 // differ, and it reopens with no changes left too: its annotations still need finishing or discarding.
328 const reused = existing?.root === loaded.root ? existing : undefined
329 const stops = changeStops(loaded.files, loaded.rows)
330 if (stops.length === 0 && !reused) return `No ${loaded.title}.`
331 let dir = reused?.dir
332 if (dir) {
333 // A Neovim that died left its socket, which `--listen` will not reuse, and maybe events nobody read, which belong
334 // to it: its `closed` would end the new one. The new Neovim starts a new file.
335 await $.process.run(['rm', '-f', `${dir}/nvim.sock`, `${dir}/events.jsonl`])
336 } else {
337 const made = await $.process.run(['sh', '-c', 'mktemp -d "${TMPDIR:-/tmp}/review-pane.XXXXXX"'])
338 dir = made.stdout.trim()
339 if (made.exitCode !== 0 || !dir.startsWith('/')) return `Could not create the review's directory: ${made.stderr.trim()}`
340 }
341 const script = `${dir}/open.sh`
342 await $.fs.write(`${dir}/review.json`, JSON.stringify({ root: loaded.root, title: loaded.title, base: loaded.base, stops }))
343 await $.fs.write(script, openScript(loaded.root, dir, `${$.plugin.root}/nvim`))
344 const review: NvimReview = { dir, root: loaded.root, title: loaded.title, offset: 0, isOpen: true }
345 await $.state.set(NVIM, review)
346 follow($, review)
347 const setAside = existing && !reused ? ` The earlier review of ${existing.root} was set aside.` : ''
348 return `${await launch($, loaded.root, script)}${setAside}`
349}
350
351/** A key the pane binds: live with `onPress`, else drawn dim and answering `reason`. */
352type Key = { hotkey: string; label: string; onPress?: () => void; reason?: string }
353
354/** Buttons laid out in rows that fit `width`, so the chrome's height is known before drawing. */
355function keyRows(keys: readonly Key[], width: number): Key[][] {
356 const rows: Key[][] = []
357 let row: Key[] = []
358 let used = 0
359 for (const key of keys) {
360 const size = key.hotkey.length + 2 + key.label.length
361 if (row.length > 0 && used + 2 + size > width) {
362 rows.push(row)
363 row = []
364 used = 0
365 }
366 used += (row.length > 0 ? 2 : 0) + size
367 row.push(key)
368 }
369 if (row.length > 0) rows.push(row)
370 return rows
371}
372
373/** The preview's display lines: labels plain, an annotation's body and answer drawn as markdown. */
374function previewLines(doc: ReviewDoc, view: ReviewView, width: number): Look[] {
375 const lines: Look[] = []
376 const push = (text: string, style: { color?: string; dim?: boolean } = {}) => {
377 for (const part of splitLines(text)) lines.push(...wrapLook(plainLook(part, style), width))
378 }
379 const pushMarkdown = (text: string) => {
380 for (const look of markdownLooks(splitLines(text))) lines.push(...wrapLook(look, width))
381 }
382 if (view.mode.kind === 'draft') {
383 const { draft } = view.mode
384 push(`${draft.editingId ? 'Edit' : 'New'} ${KINDS[draft.kind].label.toLowerCase()} on ${describeTarget(draft.target)}`, { color: KINDS[draft.kind].color })
385 return lines
386 }
387 if (view.mode.kind === 'confirmDiscard') {
388 push(`Discard ${plural(view.annotations.length, 'annotation')} and close?`, { color: 'warning' })
389 return lines
390 }
391 const active = activeAnnotation(view)
392 if (!active) return lines
393 const ordered = sortAnnotations(view.annotations)
394 const position = `${ordered.findIndex(one => one.id === active.id) + 1}/${ordered.length}`
395 push(`${KINDS[active.kind].label} · ${describeTarget(active.target)} · ${position}`, { color: KINDS[active.kind].color })
396 pushMarkdown(active.body)
397 switch (active.reply?.state) {
398 case 'pending':
399 push('Asking…', { dim: true })
400 break
401 case 'failed':
402 push(active.reply.error, { color: 'error' })
403 break
404 case 'answered':
405 lines.push({ spans: [] })
406 pushMarkdown(active.reply.text)
407 break
408 }
409 return lines
410}
411
412type Geometry = { version: number; width: number; labels: string[]; looks: Look[]; numberWidth: number; contentWidth: number; layout: Layout }
413
414/** The drawn rows depend only on the doc and the width, so they are laid out once per pair, not per key. */
415let geometry: Geometry | undefined
416
417function geometryFor(doc: ReviewDoc, version: number, width: number): Geometry {
418 if (geometry?.version === version && geometry.width === width) return geometry
419 const labels = doc.rows.map((_, index) => String(index + 1))
420 const numberWidth = labels.reduce((widest, label) => Math.max(widest, label.length), 3)
421 const contentWidth = Math.max(10, width - (numberWidth + 3))
422 const looks = markdownLooks(doc.rows.map(row => row.text))
423 const layout = layoutRows(looks.map(look => ({ text: lookText(look), hang: hangAt(look, contentWidth) })), contentWidth, true)
424 geometry = { version, width, labels, looks, numberWidth, contentWidth, layout }
425 return geometry
426}
427
428export const register: Register = on => {
429 on('session.start', async ($, e, next) => {
430 await $.command.register({
431 name: 'review-diff',
432 description: 'Review the working diff in Neovim beside Claude Code: annotate lines, ask questions, prepare a follow-up',
433 argumentHint: '[base]',
434 })
435 await $.command.register({
436 name: 'review-response',
437 description: 'Review the last response in a pane: annotate lines and prepare a follow-up',
438 })
439 // A reload killed the follower of a review still open in Neovim: pick up where it stopped.
440 const { value: review } = await $.state.get(NVIM)
441 if (review?.isOpen) {
442 if (await isAlive($, review.dir)) follow($, review)
443 else await $.state.set(NVIM, { ...review, isOpen: false })
444 }
445 return next(e)
446 })
447
448 on('command.run', { command: 'review-diff' }, async ($, e) => {
449 const arg = e.args.trim()
450 if (arg && arg !== 'base') return { text: 'Usage: /review-diff [base]' }
451 return { text: await reviewInNvim($, arg === 'base' ? 'merge-base' : 'head') }
452 })
453
454 on('command.run', { command: 'review-response' }, async ($, e) => {
455 if (e.args.trim()) return { text: 'Usage: /review-response' }
456 return { text: await startReview($) }
457 })
458
459 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
460 if (e.surface === 'mobile') {
461 const { Text } = $.ui.resolve(e)
462 return <Text dimColor>The review pane needs a surface with text fields.</Text>
463 }
464 const { Box, Text, Button, Input, Code } = $.ui.resolve(e)
465 const [docRead, { value: view }] = await Promise.all([$.state.get(DOC), $.state.get(VIEW)])
466 const doc = docRead.value
467 if (!doc || !view) return <Text dimColor>No review open. Run /review-response.</Text>
468
469 const width = Math.max(20, e.props.bodyColumns)
470 const height = Math.max(8, e.props.scroll.bodyRows)
471 const { labels, looks, numberWidth, contentWidth, layout } = geometryFor(doc, docRead.version, width)
472 // One display line: its spans, then any code, cut at the edge (wrapping already happened).
473 const drawLook = (look: Look) => [
474 <Text key="spans" wrap="truncate-end">
475 {look.spans.map((one, index) => (
476 <Text key={`span-${index}`} color={one.color} bold={one.bold} italic={one.italic} underline={one.underline} strikethrough={one.strikethrough} dimColor={one.dim}>
477 {one.text}
478 </Text>
479 ))}
480 </Text>,
481 ...(look.code ? [<Code key="code" source={look.code.text} language={look.code.language} wrap="truncate-end" />] : []),
482 ]
483 const { mode } = view
484 const draft = mode.kind === 'draft' ? mode.draft : undefined
485 const active = activeAnnotation(view)
486
487 // The handlers read `viewport`, `textRoom` and `preview`, sized below from the key rows;
488 // they run only on a press, after this draw has finished.
489 const press = (fn: (current: ReviewView) => ReviewView) => () => void updateView($, current => fn(clearNotice(current)))
490 const move = (to: (cursor: number) => number) => press(current => {
491 const moved = moveTo(doc, current, to(current.cursor))
492 return { ...moved, offset: visibleOffset(layout, moved.cursor, current.offset, viewport) }
493 })
494 const page = (direction: 1 | -1) => press(current => {
495 if (current.mode.kind !== 'browse' && current.mode.kind !== 'select') return current
496 const paged = halfPage(layout, current.cursor, visibleOffset(layout, current.cursor, current.offset, viewport), viewport, direction)
497 return { ...moveTo(doc, current, paged.cursor), offset: paged.offset }
498 })
499 const openDraft = (fn: (current: ReviewView) => ReviewView) => async () => {
500 await updateView($, current => fn(clearNotice(current)))
501 await $.ui.focus({ requestId: PANE, key: DRAFT })
502 }
503 const annotate = (kind: AnnotationKind) => openDraft(current => beginDraft(doc, current, kind))
504 // The annotation under the cursor as of the press: an `l` just before may have moved it.
505 const onActive = (fn: (current: ReviewView, id: string) => ReviewView) => (current: ReviewView) => {
506 const one = activeAnnotation(current)
507 return one ? fn(current, one.id) : current
508 }
509 const scroll = (by: (room: number) => number) =>
510 press(current => scrollPreview(current, by(textRoom), Math.max(0, preview.length - textRoom)))
511
512 // Every mode draws the same keys, the ones that do not apply dim: a key no Button binds would fall
513 // through to the prompt and take the keyboard with it.
514 const browsing = mode.kind === 'browse' || mode.kind === 'select'
515 const isBrowse = mode.kind === 'browse'
516 const isRead = mode.kind === 'read'
517 const half = (room: number) => Math.max(1, Math.floor(room / 2))
518 const why =
519 mode.kind === 'read' ? 'Press e to return to the response first.'
520 : mode.kind === 'select' ? 'Annotate the range or end it with v first.'
521 : mode.kind === 'confirmDiscard' ? 'Answer y to discard or n to keep first.'
522 : undefined
523 const when = (isOn: boolean, onPress: () => void, reason?: string): Pick<Key, 'onPress' | 'reason'> =>
524 isOn ? { onPress } : { reason: why ?? reason ?? 'Not available here.' }
525 const keys: Key[] = [
526 { hotkey: 'j', label: isRead ? 'scroll down' : 'down', ...(isRead ? { onPress: scroll(() => 1) } : when(browsing, move(cursor => cursor + 1))) },
527 { hotkey: 'k', label: isRead ? 'scroll up' : 'up', ...(isRead ? { onPress: scroll(() => -1) } : when(browsing, move(cursor => cursor - 1))) },
528 { hotkey: 'd', label: 'page down', ...(isRead ? { onPress: scroll(half) } : when(browsing, page(1))) },
529 { hotkey: 'u', label: 'page up', ...(isRead ? { onPress: scroll(room => -half(room)) } : when(browsing, page(-1))) },
530 { hotkey: 'g', label: 'top', ...when(browsing, move(() => 0)) },
531 { hotkey: 'b', label: 'bottom', ...when(browsing, move(() => doc.rows.length - 1)) },
532 mode.kind === 'confirmDiscard'
533 ? { hotkey: 'n', label: 'keep', onPress: press(current => ({ ...current, mode: { kind: 'browse' } })) }
534 : { hotkey: 'n', label: 'keep', reason: 'n answers the question q asks before discarding.' },
535 { hotkey: 'v', label: mode.kind === 'select' ? 'end range' : 'range', ...when(browsing, press(toggleSelection)) },
536 { hotkey: 'c', label: 'comment', ...when(browsing, annotate('comment')) },
537 { hotkey: 's', label: 'suggest', ...when(browsing, annotate('suggestion')) },
538 { hotkey: 'i', label: 'issue', ...when(browsing, annotate('issue')) },
539 { hotkey: 'a', label: 'ask', ...when(browsing, annotate('question')) },
540 { hotkey: 'o', label: 'overall', ...when(isBrowse, openDraft(current => beginDraft(doc, current, 'comment', { kind: 'overall' }))) },
541 { hotkey: 'h', label: 'prev note', ...when(isBrowse && view.annotations.length > 0, press(current => navigateAnnotation(current, -1)), 'No annotations yet.') },
542 { hotkey: 'l', label: 'next note', ...when(isBrowse && view.annotations.length > 0, press(current => navigateAnnotation(current, 1)), 'No annotations yet.') },
543 { hotkey: 'r', label: 'edit', ...when(isBrowse && active !== undefined, openDraft(onActive(editAnnotation)), 'No annotation under the cursor.') },
544 { hotkey: 'x', label: 'delete', ...when(isBrowse && active !== undefined, press(onActive(deleteAnnotation)), 'No annotation under the cursor.') },
545 { hotkey: 'e', label: isRead ? 'back to the response' : 'read', ...(isRead ? { onPress: press(toggleReading) } : when(isBrowse && active !== undefined, press(toggleReading), 'No annotation under the cursor.')) },
546 mode.kind === 'confirmDiscard'
547 ? { hotkey: 'y', label: 'discard', onPress: () => void endReview($) }
548 : { hotkey: 'y', label: 'finish', ...when(isBrowse, () => void finishReview($)) },
549 {
550 hotkey: 'q',
551 label: 'close',
552 ...when(isBrowse, hasWork(view) ? press(current => ({ ...current, mode: { kind: 'confirmDiscard' } })) : () => void endReview($)),
553 },
554 ]
555
556 const hints = keyRows(draft ? [] : keys, width)
557 const status = view.notice ?? (e.props.isFocused ? 'Esc returns to the prompt' : 'Run the command again or click here to use the keys')
558 const header = [
559 'Response',
560 plural(view.annotations.length, 'note'),
561 ].join(' · ')
562
563 const preview = previewLines(doc, view, width)
564 // Reading mode leaves the diff three rows and keeps one for the position line. Otherwise the
565 // preview gets a third of the pane, shrunk to what one diff row, its rule and cut line leave.
566 const draftRows = draft ? 2 : 0
567 const textRoom = mode.kind === 'read'
568 ? Math.max(1, height - 2 - hints.length - 1 - 3 - 1)
569 : Math.max(0, Math.min(Math.min(8, Math.floor(height / 3)) - draftRows, height - 2 - hints.length - 1 - 2 - draftRows))
570 const previewOffset = mode.kind === 'read' ? Math.max(0, Math.min(mode.previewOffset, preview.length - textRoom)) : 0
571 const shown = preview.slice(previewOffset, previewOffset + textRoom)
572 const isCut = shown.length < preview.length
573 const cutLine = mode.kind === 'read'
574 ? `lines ${previewOffset + 1}-${previewOffset + shown.length} of ${preview.length}`
575 : `… ${preview.length - shown.length} more lines: e to read all`
576 const previewHeight = shown.length + (isCut ? 1 : 0) + draftRows
577 const viewport = Math.max(1, height - 2 - (previewHeight > 0 ? previewHeight + 1 : 0) - hints.length)
578 const offset = visibleOffset(layout, view.cursor, view.offset, viewport)
579
580 const target = draft ? draft.target : mode.kind === 'select' ? currentTarget(view) : undefined
581 const lines = layout.display.slice(offset, offset + viewport).map((line, index) => {
582 const marks = line.isContinuation ? [] : annotationsAtRow(view.annotations, line.row)
583 const mark = PRIORITY.find(kind => marks.some(one => one.kind === kind))
584 const isCursor = line.row === view.cursor
585 const isSelected = target?.kind === 'rows' && line.row >= target.start && line.row <= target.end
586 const number = line.isContinuation ? '' : labels[line.row]!
587 return (
588 <Box key={`row-${offset + index}`} flexDirection="row" height={1}>
589 <Text>
590 <Text color={mark ? KINDS[mark].color : undefined}>{mark ? KINDS[mark].glyph : ' '}</Text>
591 <Text color="claude" bold>{isCursor && !line.isContinuation ? '›' : ' '}</Text>
592 <Text dimColor={!isCursor && !isSelected} inverse={isCursor || isSelected}>{number.padStart(numberWidth)}</Text>
593 <Text> </Text>
594 </Text>
595 <Box flexDirection="row" width={contentWidth}>
596 {drawLook(sliceLook(looks[line.row]!, line.start, line.end, line.isContinuation ? hangAt(looks[line.row]!, contentWidth) : 0))}
597 </Box>
598 </Box>
599 )
600 })
601
602 return (
603 <Box flexDirection="column" height={height} overflow="hidden">
604 <Text bold wrap="truncate-end">{header}</Text>
605 <Text dimColor={!view.notice} color={view.notice ? 'warning' : undefined} wrap="truncate-end">{status}</Text>
606 <Box flexDirection="column" height={viewport}>
607 {doc.rows.length === 0 ? <Text dimColor>The response is empty.</Text> : lines}
608 </Box>
609 {previewHeight > 0 && (
610 <Box flexDirection="column">
611 <Text dimColor>{'─'.repeat(width)}</Text>
612 {shown.map((line, index) => (
613 <Box key={`preview-${index}`} flexDirection="row" height={1}>
614 {drawLook(line)}
615 </Box>
616 ))}
617 {isCut && <Text dimColor>{cutLine}</Text>}
618 {draft && (
619 <Box flexDirection="column">
620 <Input
621 key={DRAFT}
622 autoFocus
623 value={draft.body}
624 placeholder="Enter saves · empty Enter cancels"
625 submitLabel="save"
626 onInput={value => void updateView($, current => setDraftBody(current, value))}
627 onSubmit={value => void submitDraft($, doc, value)}
628 />
629 <Text dimColor>{draft.kind === 'question' ? 'Answered here from the conversation; not sent with the review.' : 'Esc returns to the prompt; the draft stays.'}</Text>
630 </Box>
631 )}
632 </Box>
633 )}
634 {hints.map((row, index) => (
635 <Box key={`keys-${index}`} flexDirection="row" columnGap={2}>
636 {row.map(key => (
637 <Button
638 key={`key-${key.hotkey}`}
639 plain
640 hotkey={key.hotkey}
641 label={key.label}
642 dimColor={!key.onPress}
643 onPress={key.onPress ?? (() => void notify($, key.reason ?? 'Not available here.'))}
644 />
645 ))}
646 </Box>
647 ))}
648 </Box>
649 )
650 })
651}
652hooks/diff.ts 141 lines1/** One line of `git diff`: a file or hunk header, or a line numbered on the old and new side. */
2export type DiffRow = {
3 kind: 'file' | 'hunk' | 'add' | 'del' | 'ctx' | 'note'
4 text: string
5 /** Index into the parsed `files`. */
6 file: number
7 oldLine?: number
8 newLine?: number
9}
10
11/**
12 * What `parseDiff` reads is pinned against user config that changes its shape:
13 * quoted paths and blank context lines without their space (GIT_CONFIG), then
14 * colour, external diff tools and path prefixes (DIFF_FLAGS).
15 */
16export const GIT_CONFIG = ['-c', 'core.quotepath=off', '-c', 'diff.suppressBlankEmpty=false'] as const
17
18export const DIFF_FLAGS = [
19 '--no-color',
20 '--no-ext-diff',
21 '--no-relative',
22 '--src-prefix=a/',
23 '--dst-prefix=b/',
24 '-M',
25] as const
26
27const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
28
29const ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }
30
31/** Decodes a path git quoted C-style (it does for tabs, quotes and backslashes even with core.quotepath off). */
32function unquote(path: string): string {
33 if (!(path.length >= 2 && path.startsWith('"') && path.endsWith('"'))) return path
34 const bytes: number[] = []
35 const chars = Array.from(path.slice(1, -1))
36 const encoder = new TextEncoder()
37 for (let index = 0; index < chars.length; index++) {
38 const char = chars[index]!
39 if (char !== '\\') {
40 bytes.push(...encoder.encode(char))
41 continue
42 }
43 const next = chars[index + 1] ?? ''
44 const octal = /^[0-7]{3}/.exec(chars.slice(index + 1, index + 4).join(''))
45 if (octal) {
46 bytes.push(parseInt(octal[0], 8))
47 index += 3
48 } else {
49 bytes.push(ESCAPES[next] ?? next.charCodeAt(0))
50 index += 1
51 }
52 }
53 return new TextDecoder().decode(new Uint8Array(bytes))
54}
55
56const QUOTED = '"(?:[^"\\\\]|\\\\.)*"'
57const QUOTED_HEADER = new RegExp(`^(${QUOTED}) (${QUOTED})$`)
58
59function stripPrefix(path: string): string {
60 const plain = unquote(path.replace(/\t.*$/, ''))
61 if (plain === '/dev/null') return plain
62 return plain.replace(/^[ab]\//, '')
63}
64
65/** The path a `diff --git` header names, where it says so unambiguously. */
66function headerPath(rest: string): string | undefined {
67 const quoted = QUOTED_HEADER.exec(rest)
68 if (quoted) return stripPrefix(quoted[2]!)
69 return /^a\/(.+) b\/\1$/.exec(rest)?.[1]
70}
71
72/**
73 * Parses `git diff` output (with DIFF_FLAGS) into rows: a `file` row per file,
74 * a `hunk` row per hunk, and its lines numbered on the old and new side.
75 */
76export function parseDiff(text: string): { files: string[]; rows: DiffRow[] } {
77 const files: string[] = []
78 const rows: DiffRow[] = []
79 let file = -1
80 let oldLine = 0
81 let newLine = 0
82 let inHunk = false
83 let notes: string[] = []
84 let oldPath = ''
85 /** The file's path once known; its row opens at the first line that belongs to it. */
86 let pending: string | undefined
87
88 const flush = () => {
89 if (file >= 0 || pending === undefined) return
90 file = files.length
91 files.push(pending)
92 const status = notes.length > 0 ? ` (${notes.join(', ')})` : ''
93 rows.push({ kind: 'file', text: `${pending}${status}`, file })
94 }
95
96 for (const line of text.split('\n')) {
97 if (line.startsWith('diff --git ')) {
98 flush()
99 inHunk = false
100 notes = []
101 oldPath = ''
102 file = -1
103 pending = headerPath(line.slice('diff --git '.length))
104 continue
105 }
106 if (!inHunk) {
107 if (line.startsWith('new file mode')) notes.push('new file')
108 else if (line.startsWith('deleted file mode')) notes.push('deleted')
109 else if (line.startsWith('new mode ')) notes.push('mode changed')
110 else if (line.startsWith('rename from ')) notes.push(`renamed from ${unquote(line.slice('rename from '.length))}`)
111 else if (line.startsWith('rename to ')) pending = unquote(line.slice('rename to '.length))
112 else if (line.startsWith('--- ')) oldPath = stripPrefix(line.slice(4))
113 else if (line.startsWith('+++ ')) {
114 const newPath = stripPrefix(line.slice(4))
115 pending = newPath === '/dev/null' ? oldPath : newPath
116 flush()
117 } else if (line.startsWith('Binary files ')) {
118 const named = / and (.*) differ$/.exec(line)?.[1]
119 pending ??= named === undefined ? 'binary file' : stripPrefix(named)
120 flush()
121 rows.push({ kind: 'note', text: 'Binary file differs', file })
122 }
123 }
124 const hunk = HUNK.exec(line)
125 if (hunk && file >= 0) {
126 inHunk = true
127 oldLine = Number(hunk[1])
128 newLine = Number(hunk[2])
129 rows.push({ kind: 'hunk', text: line, file })
130 continue
131 }
132 if (!inHunk || file < 0) continue
133 if (line.startsWith('+')) rows.push({ kind: 'add', text: line, file, newLine: newLine++ })
134 else if (line.startsWith('-')) rows.push({ kind: 'del', text: line, file, oldLine: oldLine++ })
135 else if (line.startsWith(' ')) rows.push({ kind: 'ctx', text: line, file, oldLine: oldLine++, newLine: newLine++ })
136 else if (line.startsWith('\\')) rows.push({ kind: 'note', text: line, file })
137 }
138 flush()
139 return { files, rows }
140}
141hooks/format.ts 112 lines1import type { Annotation, AnnotationKind, ReviewDoc, ReviewTarget } from '../types'
2import { splitLines } from './layout'
3import { KINDS, sortAnnotations, targetRows } from './model'
4import type { AskEvent, FileNote } from './nvim'
5
6function range(first: number, last: number): string {
7 return first === last ? `line ${first}` : `lines ${first}-${last}`
8}
9
10/** Where a pane target points, in words: `Response line 4`. */
11export function describeTarget(target: ReviewTarget): string {
12 return target.kind === 'overall' ? 'Overall response' : `Response ${range(target.start + 1, target.end + 1)}`
13}
14
15const describeFile = (file: string, start: number, end: number) => `\`${file}\` ${range(start, end)}`
16
17function fence(lines: readonly string[], language: string): string[] {
18 const longest = Math.max(2, ...lines.map(line => Math.max(0, ...(line.match(/`+/g) ?? []).map(run => run.length))))
19 const ticks = '`'.repeat(longest + 1)
20 return [`${ticks}${language}`, ...lines, ticks]
21}
22
23/** The fence language for a file: its extension, which markdown highlighters mostly know. */
24function language(file: string): string {
25 const name = file.slice(file.lastIndexOf('/') + 1)
26 const dot = name.lastIndexOf('.')
27 return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
28}
29
30const quoteResponse = (lines: readonly string[]) => lines.map(line => `> ${line}`)
31
32type Item = { kind: AnnotationKind; where: string; quote: string[]; body: string }
33
34/** The follow-up prompt for the person to edit and send: one numbered entry per annotation. */
35function formatItems(intro: string, items: readonly Item[]): string {
36 const output = [intro, '']
37 items.forEach((item, index) => {
38 const marker = `${index + 1}. `
39 const indent = ' '.repeat(marker.length)
40 output.push(`${marker}**[${KINDS[item.kind].label.toUpperCase()}]** ${item.where}`, '')
41 if (item.quote.length > 0) output.push(...item.quote.map(line => `${indent}${line}`), '')
42 output.push(...splitLines(item.body).map(line => `${indent}${line}`))
43 if (index < items.length - 1) output.push('')
44 })
45 return output.join('\n')
46}
47
48/** The pane's review of a response. Questions are left out: they were answered in the pane. */
49export function formatReview(doc: ReviewDoc, annotations: readonly Annotation[]): string {
50 const ordered = sortAnnotations(annotations.filter(one => one.kind !== 'question'))
51 if (ordered.length === 0) return ''
52 return formatItems(
53 'I reviewed your previous response. Please address these annotations.',
54 ordered.map(one => ({
55 kind: one.kind,
56 where: describeTarget(one.target),
57 quote: quoteResponse(targetRows(doc, one.target).map(row => row.text)),
58 body: one.body,
59 })),
60 )
61}
62
63/** Neovim's review of the working diff, overall comments first, the rest in the order sent. Questions are left out. */
64export function formatFileReview(title: string, notes: readonly FileNote[]): string {
65 const kept = notes.filter(one => one.kind !== 'question')
66 if (kept.length === 0) return ''
67 const ordered = [...kept.filter(one => one.file === undefined), ...kept.filter(one => one.file !== undefined)]
68 return formatItems(
69 `I reviewed the ${title}. Please address these annotations.`,
70 ordered.map(one => ({
71 kind: one.kind,
72 where: one.file === undefined ? 'The changes as a whole' : describeFile(one.file, one.start ?? 1, one.end ?? one.start ?? 1),
73 quote: one.file !== undefined && one.lines ? fence(one.lines, language(one.file)) : [],
74 body: one.body,
75 })),
76 )
77}
78
79const ANSWER_RULES =
80 'Answer it from what you know of this conversation and the excerpt below. You cannot use tools here. Answer in plain text, concisely; say so when you would need to look something up to be sure.'
81
82/** What `$.model.fork` is asked for a pane question: the lines it is about and the question. */
83export function formatQuestion(doc: ReviewDoc, annotation: Annotation): string {
84 const quoted = quoteResponse(targetRows(doc, annotation.target).map(row => row.text))
85 return [
86 `I am reviewing your previous response in a side pane and have a question. ${ANSWER_RULES}`,
87 '',
88 `Location: ${describeTarget(annotation.target)}`,
89 ...(quoted.length > 0 ? ['', 'The lines the question is about:', ...quoted] : []),
90 '',
91 `Question: ${annotation.body}`,
92 ].join('\n')
93}
94
95/** What `$.model.fork` is asked for a question from Neovim: the file around the lines, the lines and the question. */
96export function formatFileQuestion(title: string, ask: AskEvent): string {
97 const lang = language(ask.file)
98 const contextEnd = ask.context.start + ask.context.lines.length - 1
99 const isWider = ask.context.start !== ask.start || contextEnd !== ask.end
100 return [
101 `I am reviewing the ${title} in this repository in Neovim and have a question. ${ANSWER_RULES}`,
102 '',
103 `Location: ${describeFile(ask.file, ask.start, ask.end)}`,
104 ...(isWider ? ['', `The file around them (${range(ask.context.start, contextEnd)}, as it is now):`, ...fence(ask.context.lines, lang)] : []),
105 '',
106 'The lines the question is about:',
107 ...fence(ask.lines, lang),
108 '',
109 `Question: ${ask.body}`,
110 ].join('\n')
111}
112hooks/layout.ts 153 lines1const TAB = ' '
2
3/** Tabs as spaces and control characters made visible, so a row draws as one line of known width. */
4export function sanitize(text: string): string {
5 let out = ''
6 for (const char of text) {
7 const code = char.codePointAt(0) ?? 0
8 if (char === '\t') out += TAB
9 else if (code === 0x7f) out += '␡'
10 else if (code < 0x20) out += String.fromCodePoint(0x2400 + code)
11 else if ((code >= 0x80 && code <= 0x9f) || code === 0x061c || code === 0x200e || code === 0x200f || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069))
12 out += `\\u{${code.toString(16).toUpperCase()}}`
13 else out += char
14 }
15 return out
16}
17
18/** Cells a code point takes: two for the wide East Asian ranges and emoji, else one. */
19function cells(code: number): number {
20 return (code >= 0x1100 && code <= 0x115f) ||
21 (code >= 0x2e80 && code <= 0xa4cf) ||
22 (code >= 0xac00 && code <= 0xd7a3) ||
23 (code >= 0xf900 && code <= 0xfaff) ||
24 (code >= 0xfe30 && code <= 0xfe4f) ||
25 (code >= 0xff00 && code <= 0xff60) ||
26 (code >= 0xffe0 && code <= 0xffe6) ||
27 (code >= 0x1f300 && code <= 0x1faff) ||
28 (code >= 0x20000 && code <= 0x3fffd)
29 ? 2
30 : 1
31}
32
33export function textWidth(text: string): number {
34 let width = 0
35 for (const char of text) width += cells(char.codePointAt(0) ?? 0)
36 return width
37}
38
39/**
40 * Cuts text into lines of at most `width` cells, as `[start, end)` offsets; an empty text is one
41 * empty line. With `byWords` a line breaks at its last space, as prose wraps, and the space it
42 * breaks at is drawn on neither line. Lines after the first leave `hang` cells for an indent.
43 */
44export function wrap(text: string, width: number, byWords = false, hang = 0): Array<[number, number]> {
45 let room = Math.max(1, width)
46 const lines: Array<[number, number]> = []
47 let start = 0
48 let used = 0
49 let space = -1
50 for (let at = 0; at < text.length; ) {
51 const code = text.codePointAt(at) ?? 0
52 const length = code > 0xffff ? 2 : 1
53 const size = cells(code)
54 if (used + size > room && at > start) {
55 room = Math.max(1, width - hang)
56 if (byWords && code === 0x20) {
57 lines.push([start, at])
58 start = at + 1
59 used = 0
60 space = -1
61 at += 1
62 continue
63 }
64 // The word carried down must fit beside the indent, or the line is cut where it stands.
65 const breaksAtSpace =
66 byWords && space > start && text.slice(start, space).trim() !== '' && textWidth(text.slice(space + 1, at)) + size <= room
67 lines.push([start, breaksAtSpace ? space : at])
68 start = breaksAtSpace ? space + 1 : at
69 used = textWidth(text.slice(start, at))
70 space = -1
71 }
72 if (code === 0x20) space = at
73 used += size
74 at += length
75 }
76 lines.push([start, text.length])
77 return lines
78}
79
80export function splitLines(text: string): string[] {
81 return text.split(/\r\n|\r|\n/)
82}
83
84/** One wrapped line: its row, and where in the row's drawn text it starts and ends. */
85type DisplayRow = { row: number; start: number; end: number; isContinuation: boolean }
86
87/** The rows as drawn at one width, and where each logical row's first display row is. */
88export type Layout = { display: DisplayRow[]; first: number[] }
89
90/** Wraps each row's drawn text (sanitized, markers already styled away) at `width`, leaving its `hang`. */
91export function layoutRows(rows: ReadonlyArray<{ text: string; hang: number }>, width: number, byWords: boolean): Layout {
92 const display: DisplayRow[] = []
93 const first: number[] = []
94 rows.forEach(({ text, hang }, index) => {
95 first.push(display.length)
96 wrap(text, width, byWords, hang).forEach(([start, end], part) => display.push({ row: index, start, end, isContinuation: part > 0 }))
97 })
98 return { display, first }
99}
100
101function rowSpan({ display, first }: Layout, row: number): [number, number] {
102 const start = first[row] ?? 0
103 return [start, (first[row + 1] ?? display.length) - 1]
104}
105
106/**
107 * The first display row to draw so the cursor's row shows, moving `offset` as
108 * little as it can. A row taller than the view keeps the view while any of it
109 * shows, so half pages can walk through it.
110 */
111export function visibleOffset(layout: Layout, cursor: number, offset: number, height: number): number {
112 const { display } = layout
113 if (display.length === 0) return 0
114 const room = Math.max(1, height)
115 const [start, end] = rowSpan(layout, cursor)
116 const max = Math.max(0, display.length - room)
117 let next = Math.max(0, Math.min(Math.trunc(offset), max))
118 if (end - start + 1 <= room) {
119 if (start < next) next = start
120 else if (end >= next + room) next = end - room + 1
121 } else if (end < next || start >= next + room) {
122 next = start
123 }
124 return Math.min(next, max)
125}
126
127/** Half a page down (`direction` 1) or up (-1): the cursor and the view move together. */
128export function halfPage(
129 layout: Layout,
130 cursor: number,
131 offset: number,
132 height: number,
133 direction: 1 | -1,
134): { cursor: number; offset: number } {
135 const { display } = layout
136 if (display.length === 0) return { cursor, offset }
137 const room = Math.max(1, height)
138 const distance = Math.max(1, Math.floor(room / 2))
139 const max = Math.max(0, display.length - room)
140 const [start, end] = rowSpan(layout, cursor)
141 const from = direction < 0 ? Math.min(end, offset + room - 1) : Math.max(start, offset)
142 const scrolled = Math.max(0, Math.min(max, offset + direction * distance))
143 const land = (target: number) => {
144 const next = display[Math.max(0, Math.min(display.length - 1, target))]!.row
145 return { cursor: next, offset: visibleOffset(layout, next, scrolled, height) }
146 }
147 const paged = land(from + direction * distance)
148 // Paging that would leave the cursor and view where they were (the end of the document, or a row the
149 // view snaps back to whole) steps off the cursor's row instead.
150 if (paged.cursor === cursor && paged.offset === offset) return land(direction > 0 ? end + 1 : start - 1)
151 return paged
152}
153hooks/nvim.ts 99 lines1import type { AnnotationKind } from '../types'
2import type { DiffRow } from './diff'
3
4/** A place Neovim's quickfix list jumps to: the first line of a run of changes in the working file. */
5export type Stop = { file: string; line: number; text: string }
6
7/**
8 * One stop per run of changed lines, at its first line in the working file; a run that only
9 * deletes stops at the line after it. A file with no hunk (binary, mode change) stops at line 1.
10 */
11export function changeStops(files: readonly string[], rows: readonly DiffRow[]): Stop[] {
12 const stops: Stop[] = []
13 let file = -1
14 let hasStop = true
15 let next = 1
16 let inRun = false
17 const close = () => {
18 if (file >= 0 && !hasStop) stops.push({ file: files[file]!, line: 1, text: 'no line changes' })
19 }
20 for (const row of rows) {
21 if (row.kind === 'file') {
22 close()
23 file = row.file
24 hasStop = false
25 inRun = false
26 continue
27 }
28 if (row.kind === 'hunk') {
29 next = Number(/ \+(\d+)/.exec(row.text)?.[1] ?? 1)
30 inRun = false
31 continue
32 }
33 if (row.kind === 'add' || row.kind === 'del') {
34 if (!inRun) stops.push({ file: files[file]!, line: Math.max(1, row.newLine ?? next), text: `${row.text[0]} ${row.text.slice(1).trim()}` })
35 hasStop = true
36 inRun = true
37 if (row.newLine !== undefined) next = row.newLine + 1
38 continue
39 }
40 if (row.kind === 'ctx') {
41 inRun = false
42 next = row.newLine! + 1
43 }
44 }
45 close()
46 return stops
47}
48
49export const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`
50
51/** Runs after the person's config: lazy.nvim resets the runtimepath while it sets up. */
52const START = 'lua vim.opt.rtp:append(vim.env.REVIEW_PANE_RTP); require("review_pane").start(vim.env.REVIEW_PANE_DIR)'
53
54/** The script that opens the review, run by whatever splits the terminal or by the person. Paths reach Lua as environment variables, so none is quoted for Vim. */
55export function openScript(root: string, dir: string, runtime: string): string {
56 return [
57 '#!/bin/sh',
58 `cd ${shellQuote(root)} || exit 1`,
59 `REVIEW_PANE_DIR=${shellQuote(dir)} REVIEW_PANE_RTP=${shellQuote(runtime)} exec nvim --listen ${shellQuote(`${dir}/nvim.sock`)} -c ${shellQuote(START)}`,
60 '',
61 ].join('\n')
62}
63
64/**
65 * The `--remote-expr` calling the Lua side's `fn` with `payload` as JSON. Inside a single-quoted
66 * Vim string only `'` needs escaping (doubled), and JSON.stringify writes no raw newline.
67 */
68export function luaCall(fn: 'answer' | 'finished', payload: unknown): string {
69 return `luaeval("require'review_pane'.${fn}(_A)", '${JSON.stringify(payload).replaceAll("'", "''")}')`
70}
71
72/** An annotation as Neovim sends it to finish: on a file's lines, or with no file on the changes as a whole. */
73export type FileNote = { kind: AnnotationKind; file?: string; start?: number; end?: number; lines?: string[]; body: string }
74
75export type AskEvent = {
76 type: 'ask'
77 id: number
78 token: string
79 file: string
80 start: number
81 end: number
82 lines: string[]
83 context: { start: number; lines: string[] }
84 body: string
85}
86
87/** What Neovim appends to events.jsonl. */
88export type NvimEvent = AskEvent | { type: 'finish'; annotations: FileNote[] } | { type: 'closed'; count: number }
89
90export function parseEvent(line: string): NvimEvent | undefined {
91 try {
92 const value = JSON.parse(line) as { type?: unknown }
93 if (value.type === 'ask' || value.type === 'finish' || value.type === 'closed') return value as NvimEvent
94 } catch {
95 // An unreadable line is reported by the caller.
96 }
97 return undefined
98}
99hooks/style.ts 292 lines1import { sanitize, textWidth, wrap } from './layout'
2
3/** A run of text drawn in one style; colours are theme keys, so they follow the person's theme. */
4export type Span = {
5 text: string
6 color?: string
7 bold?: boolean
8 italic?: boolean
9 underline?: boolean
10 strikethrough?: boolean
11 dim?: boolean
12}
13
14type Style = Omit<Span, 'text'>
15
16/** Code the engine's highlighter colours by `language`. */
17export type CodeText = { text: string; language?: string }
18
19/**
20 * How one row draws: styled spans, then code when the row holds some. Its text is what wraps;
21 * `hang` starts each wrapped line after the first, as a list item's text lines up under itself.
22 */
23export type Look = { spans: Span[]; code?: CodeText; hang?: Span }
24
25const span = (text: string, style: Style = {}): Span => ({ text: sanitize(text), ...style })
26
27export function lookText(look: Look): string {
28 return look.spans.map(one => one.text).join('') + (look.code?.text ?? '')
29}
30
31/** The cells a wrapped line of `look` is indented by at `width`: its hang, at most half the line. */
32export const hangAt = (look: Look, width: number): number =>
33 Math.min(look.hang ? textWidth(look.hang.text) : 0, Math.floor(width / 2))
34
35/**
36 * The part of `look` from `start` to `end` in its text, for one wrapped display line, behind
37 * `indent` cells of its hang (the hang's characters are one cell each).
38 */
39export function sliceLook(look: Look, start: number, end: number, indent = 0): Look {
40 const spans: Span[] = indent > 0 && look.hang ? [{ ...look.hang, text: look.hang.text.slice(0, indent) }] : []
41 let at = 0
42 for (const one of look.spans) {
43 const from = Math.max(start, at)
44 const to = Math.min(end, at + one.text.length)
45 if (from < to) spans.push({ ...one, text: one.text.slice(from - at, to - at) })
46 at += one.text.length
47 }
48 if (!look.code) return { spans }
49 const text = look.code.text.slice(Math.max(0, start - at), Math.max(0, end - at))
50 return text ? { spans, code: { ...look.code, text } } : { spans }
51}
52
53// A backtick fence's info string holds no backtick, so ```a``` on one line is inline code, not a fence.
54const FENCE = /^ {0,3}(?:(`{3,})([^`]*)|(~{3,})(.*))$/
55const FENCE_END = /^ {0,3}(`{3,}|~{3,})\s*$/
56const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
57const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(\s+#+)?\s*$/
58const RULE = /^ {0,3}([-*_])( *\1){2,} *$/
59const QUOTE = /^ {0,3}>\s?(.*)$/
60const BULLET = /^(\s*)([-*+])\s+(.*)$/
61const NUMBERED = /^(\s*)(\d+[.)])\s+(.*)$/
62
63/**
64 * Markdown drawn one source line per row, as Claude Code draws a reply: emphasis, inline code,
65 * headings, quotes and tables styled, fenced code highlighted by its language, markers hidden.
66 * One row per line keeps row indices those of the text the annotations quote.
67 */
68export function markdownLooks(lines: readonly string[]): Look[] {
69 const looks: Look[] = []
70 let fence: { marker: string; language: string } | undefined
71 for (let index = 0; index < lines.length; index++) {
72 const line = lines[index]!
73 if (fence) {
74 const end = FENCE_END.exec(line)?.[1]
75 const closes = end !== undefined && end[0] === fence.marker[0] && end.length >= fence.marker.length
76 looks.push(closes ? { spans: [] } : codeLine(line, fence.language))
77 if (closes) fence = undefined
78 continue
79 }
80 const opener = FENCE.exec(line)
81 if (opener) {
82 fence = { marker: (opener[1] ?? opener[3])!, language: (opener[2] ?? opener[4] ?? '').trim().split(/\s+/)[0] ?? '' }
83 looks.push({ spans: fence.language ? [span(fence.language, { dim: true })] : [] })
84 continue
85 }
86 if (isTableStart(line, lines[index + 1])) {
87 let end = index + 2
88 while (end < lines.length && isTableRow(lines[end]!)) end++
89 looks.push(...tableLooks(lines.slice(index, end)))
90 index = end - 1
91 continue
92 }
93 looks.push(blockLook(line))
94 }
95 return looks
96}
97
98function codeLine(line: string, language: string): Look {
99 // A fence with no language draws as Claude Code draws one: plain, in the inline-code colour.
100 return language ? { spans: [], code: { text: sanitize(line), language } } : { spans: [span(line, { color: 'permission' })] }
101}
102
103function blockLook(line: string): Look {
104 const heading = HEADING.exec(line)
105 if (heading) return { spans: inline(heading[2]!, heading[1]!.length === 1 ? { bold: true, italic: true, underline: true } : { bold: true }) }
106 if (RULE.test(line)) return { spans: [span('─'.repeat(24), { dim: true })] }
107 const quote = QUOTE.exec(line)
108 if (quote) {
109 const bar = span('▎ ', { dim: true })
110 return { spans: [bar, ...inline(quote[1]!, { italic: true })], hang: bar }
111 }
112 const bullet = BULLET.exec(line) ?? NUMBERED.exec(line)
113 if (bullet) {
114 const marker = span(`${bullet[1]}${bullet[2]} `)
115 return { spans: [marker, ...inline(bullet[3]!)], hang: span(' '.repeat(textWidth(marker.text))) }
116 }
117 return { spans: inline(line) }
118}
119
120const isTableRow = (line: string) => line.trim().startsWith('|')
121
122/** A header row, then a rule of as many columns: a lone `---` under a row with pipes is not one. */
123function isTableStart(line: string, next: string | undefined): boolean {
124 return isTableRow(line) && next !== undefined && next.includes('|') && TABLE_RULE.test(next) &&
125 cellsOf(next).length === cellsOf(line).length
126}
127
128function cellsOf(line: string): string[] {
129 const cells: string[] = []
130 let cell = ''
131 for (let at = 0; at < line.length; at++) {
132 const char = line[at]!
133 if (char === '\\' && line[at + 1] === '|') {
134 cell += '|'
135 at++
136 } else if (char === '|') {
137 cells.push(cell.trim())
138 cell = ''
139 } else cell += char
140 }
141 cells.push(cell.trim())
142 const trimmed = line.trim()
143 return cells.slice(trimmed.startsWith('|') ? 1 : 0, trimmed.endsWith('|') && !trimmed.endsWith('\\|') ? -1 : undefined)
144}
145
146/** A table's rows with their columns padded to one width each; the header bold, the rule drawn as a line. */
147function tableLooks(lines: readonly string[]): Look[] {
148 const rows = lines.map((line, index) => (index === 1 ? undefined : cellsOf(line).map(cell => inline(cell, index === 0 ? { bold: true } : {}))))
149 const widths: number[] = []
150 for (const row of rows) row?.forEach((cell, column) => { widths[column] = Math.max(widths[column] ?? 0, textWidth(cell.map(one => one.text).join(''))) })
151 const divider = span(' │ ', { dim: true })
152 return rows.map(row => {
153 if (!row) return { spans: [span(widths.map(width => '─'.repeat(width)).join('─┼─'), { dim: true })] }
154 const spans: Span[] = []
155 widths.forEach((width, column) => {
156 const cell = row[column] ?? []
157 if (column > 0) spans.push(divider)
158 spans.push(...cell, span(' '.repeat(width - textWidth(cell.map(one => one.text).join('')))))
159 })
160 return { spans }
161 })
162}
163
164const PUNCTUATION = /[!-/:-@[-`{-~]/
165const WORD = /[\p{L}\p{N}]/u
166
167/** Inline markdown as spans: code, strong, emphasis, strikethrough, links and escapes; anything unclosed stays literal. */
168export function inline(text: string, style: Style = {}): Span[] {
169 const spans: Span[] = []
170 let literal = ''
171 const flush = () => {
172 if (literal) spans.push(span(literal, style))
173 literal = ''
174 }
175 let at = 0
176 while (at < text.length) {
177 const rest = text.slice(at)
178 const char = text[at]!
179 if (char === '\\' && PUNCTUATION.test(text[at + 1] ?? '')) {
180 literal += text[at + 1]
181 at += 2
182 continue
183 }
184 if (char === '`') {
185 const code = codeSpan(text, at)
186 if (code) {
187 flush()
188 spans.push(span(code.text, { ...style, color: 'permission' }))
189 at = code.end
190 } else {
191 const run = /^`+/.exec(rest)![0]
192 literal += run
193 at += run.length
194 }
195 continue
196 }
197 const link = /^(!?)\[([^\]]*)\]\(([^)\s]*)(?:\s+"[^"]*")?\)/.exec(rest)
198 if (link) {
199 flush()
200 spans.push(...inline(link[2] || link[3]!, link[1] ? { ...style, dim: true } : { ...style, underline: true }))
201 at += link[0].length
202 continue
203 }
204 const autolink = /^<(https?:\/\/[^>\s]+)>/.exec(rest)
205 if (autolink) {
206 flush()
207 spans.push(span(autolink[1]!, { ...style, underline: true }))
208 at += autolink[0].length
209 continue
210 }
211 const opens = ['***', '**', '__', '~~', '*', '_'].filter(one => rest.startsWith(one))
212 // The longest delimiter that closes wins: `***a* b**` is strong text holding emphasis.
213 const delimiter = opens.find(one => closing(text, one, at) !== undefined) ?? opens[0]
214 if (delimiter) {
215 const close = closing(text, delimiter, at)
216 if (close !== undefined) {
217 flush()
218 const inner =
219 delimiter === '~~' ? { strikethrough: true } : delimiter === '***' ? { bold: true, italic: true } : delimiter.length === 2 ? { bold: true } : { italic: true }
220 spans.push(...inline(text.slice(at + delimiter.length, close), { ...style, ...inner }))
221 at = close + delimiter.length
222 continue
223 }
224 literal += delimiter
225 at += delimiter.length
226 continue
227 }
228 literal += char
229 at++
230 }
231 flush()
232 return spans
233}
234
235/** A code span opening at `at`: its text and where it ends; undefined when its backticks never close. */
236function codeSpan(text: string, at: number): { text: string; end: number } | undefined {
237 const run = /^`+/.exec(text.slice(at))![0]
238 for (let close = text.indexOf(run, at + run.length); close !== -1; close = text.indexOf(run, close + 1)) {
239 // The closing run is exactly as long as the opening one.
240 if (text[close - 1] === '`' || text[close + run.length] === '`') continue
241 let code = text.slice(at + run.length, close)
242 if (code.length > 1 && code.startsWith(' ') && code.endsWith(' ') && code.trim()) code = code.slice(1, -1)
243 return { text: code, end: close + run.length }
244 }
245 return undefined
246}
247
248/**
249 * Where `delimiter` opened at `at` closes, under CommonMark's flanking rules in short; undefined
250 * when it does not. Escapes and code spans close nothing, and a run that opens emphasis of its
251 * own is stepped over to its closer, so `*a **b** c*` closes at the last star. A longer closing
252 * run closes with its first characters: `**bold***` leaves the last star literal.
253 */
254function closing(text: string, delimiter: string, at: number): number | undefined {
255 const open = at + delimiter.length
256 const mark = delimiter[0]!
257 if (!text[open] || /\s/.test(text[open]!)) return undefined
258 if (mark === '_' && WORD.test(text[at - 1] ?? '')) return undefined
259 for (let close = open; close < text.length; ) {
260 const char = text[close]!
261 if (char === '\\') {
262 close += 2
263 continue
264 }
265 if (char === '`') {
266 close = codeSpan(text, close)?.end ?? close + /^`+/.exec(text.slice(close))![0].length
267 continue
268 }
269 if (char !== mark) {
270 close++
271 continue
272 }
273 const run = /^(.)\1*/.exec(text.slice(close))![0].length
274 const canClose = close > open && !/\s/.test(text[close - 1]!) && !(mark === '_' && WORD.test(text[close + run] ?? ''))
275 if (canClose && run >= delimiter.length) return close
276 const nested = mark.repeat(Math.min(run, 3))
277 const inner = canClose ? undefined : closing(text, nested, close)
278 close = inner === undefined ? close + run : inner + nested.length
279 }
280 return undefined
281}
282
283export function plainLook(text: string, style: Style = {}): Look {
284 return { spans: [span(text, style)] }
285}
286
287/** A look cut into display lines of at most `width` cells. */
288export function wrapLook(look: Look, width: number): Look[] {
289 const indent = hangAt(look, width)
290 return wrap(lookText(look), width, true, indent).map(([start, end], part) => sliceLook(look, start, end, part > 0 ? indent : 0))
291}
292hooks/model.ts 175 lines1import type {
2 Annotation,
3 AnnotationKind,
4 Reply,
5 ReviewDoc,
6 ReviewTarget,
7 ReviewView,
8 Row,
9} from '../types'
10
11/** How each kind of annotation is named and marked, in the order the gutter prefers one mark over another. */
12export const KINDS: Record<AnnotationKind, { label: string; glyph: string; color: string }> = {
13 issue: { label: 'Issue', glyph: '!', color: 'error' },
14 suggestion: { label: 'Suggestion', glyph: '+', color: 'success' },
15 question: { label: 'Question', glyph: '?', color: 'warning' },
16 comment: { label: 'Comment', glyph: '•', color: 'suggestion' },
17}
18
19export function createView(): ReviewView {
20 return { cursor: 0, offset: 0, mode: { kind: 'browse' }, annotations: [], nextId: 1 }
21}
22
23/** Whether the review holds work a reload would lose: annotations or an open draft. */
24export function hasWork(view: ReviewView): boolean {
25 return view.annotations.length > 0 || view.mode.kind === 'draft'
26}
27
28export function clearNotice(view: ReviewView): ReviewView {
29 const { notice: _, ...rest } = view
30 return rest
31}
32
33function clearActive(view: ReviewView): ReviewView {
34 const { activeId: _, ...rest } = view
35 return rest
36}
37
38function clampRow(doc: ReviewDoc, row: number): number {
39 return Math.max(0, Math.min(doc.rows.length - 1, Math.trunc(row)))
40}
41
42export function currentTarget(view: ReviewView): ReviewTarget {
43 const anchor = view.mode.kind === 'select' ? view.mode.anchor : view.cursor
44 return { kind: 'rows', start: Math.min(anchor, view.cursor), end: Math.max(anchor, view.cursor) }
45}
46
47/** Why a target cannot be annotated, or undefined when it can. */
48function targetProblem(doc: ReviewDoc, target: ReviewTarget): string | undefined {
49 if (target.kind === 'overall') return undefined
50 if (doc.rows.length === 0) return 'Nothing to annotate'
51 return undefined
52}
53
54/** Overall annotations first, then by position; the sort is stable, so ties keep the order they were made in. */
55export function sortAnnotations(annotations: readonly Annotation[]): Annotation[] {
56 return [...annotations].sort((left, right) => {
57 if (left.target.kind !== right.target.kind) return left.target.kind === 'overall' ? -1 : 1
58 if (left.target.kind === 'rows' && right.target.kind === 'rows') {
59 return left.target.start - right.target.start || left.target.end - right.target.end
60 }
61 return 0
62 })
63}
64
65export function annotationsAtRow(annotations: readonly Annotation[], row: number): Annotation[] {
66 return sortAnnotations(
67 annotations.filter(one => one.target.kind === 'rows' && one.target.start <= row && one.target.end >= row),
68 )
69}
70
71export function activeAnnotation(view: ReviewView): Annotation | undefined {
72 return view.annotations.find(one => one.id === view.activeId) ?? annotationsAtRow(view.annotations, view.cursor)[0]
73}
74
75export function moveTo(doc: ReviewDoc, view: ReviewView, row: number): ReviewView {
76 if (view.mode.kind !== 'browse' && view.mode.kind !== 'select') return view
77 return { ...clearActive(view), cursor: clampRow(doc, row) }
78}
79
80export function toggleSelection(view: ReviewView): ReviewView {
81 if (view.mode.kind === 'browse') return { ...clearActive(view), mode: { kind: 'select', anchor: view.cursor } }
82 if (view.mode.kind === 'select') return { ...view, mode: { kind: 'browse' } }
83 return view
84}
85
86export function toggleReading(view: ReviewView): ReviewView {
87 if (view.mode.kind === 'browse') return { ...view, mode: { kind: 'read', previewOffset: 0 } }
88 if (view.mode.kind === 'read') return { ...view, mode: { kind: 'browse' } }
89 return view
90}
91
92/** Scrolls the preview in reading mode, within `[0, max]`. */
93export function scrollPreview(view: ReviewView, by: number, max: number): ReviewView {
94 if (view.mode.kind !== 'read') return view
95 return { ...view, mode: { kind: 'read', previewOffset: Math.max(0, Math.min(max, view.mode.previewOffset + by)) } }
96}
97
98export function beginDraft(
99 doc: ReviewDoc,
100 view: ReviewView,
101 kind: AnnotationKind,
102 target: ReviewTarget = currentTarget(view),
103): ReviewView {
104 if (view.mode.kind !== 'browse' && view.mode.kind !== 'select') return view
105 const problem = targetProblem(doc, target)
106 if (problem) return { ...view, notice: problem }
107 return { ...clearActive(view), mode: { kind: 'draft', draft: { kind, target, body: '' } } }
108}
109
110export function setDraftBody(view: ReviewView, body: string): ReviewView {
111 return view.mode.kind === 'draft' ? { ...view, mode: { kind: 'draft', draft: { ...view.mode.draft, body } } } : view
112}
113
114/** Saves the open draft; an empty body cancels it. Answers the saved annotation. */
115export function saveDraft(view: ReviewView, body: string): { view: ReviewView; saved?: Annotation } {
116 if (view.mode.kind !== 'draft') return { view }
117 if (!body.trim()) return { view: { ...view, mode: { kind: 'browse' } } }
118 const { kind, target, editingId } = view.mode.draft
119 // An edit drops the answer to the previous text.
120 const saved: Annotation = { id: editingId ?? `annotation-${view.nextId}`, kind, target, body, reply: null }
121 const annotations = editingId
122 ? view.annotations.map(one => (one.id === editingId ? saved : one))
123 : [...view.annotations, saved]
124 return {
125 view: { ...view, annotations, activeId: saved.id, nextId: editingId ? view.nextId : view.nextId + 1, mode: { kind: 'browse' } },
126 saved,
127 }
128}
129
130export function editAnnotation(view: ReviewView, id: string): ReviewView {
131 if (view.mode.kind !== 'browse') return view
132 const annotation = view.annotations.find(one => one.id === id)
133 if (!annotation) return view
134 return {
135 ...view,
136 activeId: id,
137 mode: { kind: 'draft', draft: { kind: annotation.kind, target: annotation.target, editingId: id, body: annotation.body } },
138 }
139}
140
141export function deleteAnnotation(view: ReviewView, id: string): ReviewView {
142 if (view.mode.kind !== 'browse') return view
143 return { ...clearActive(view), annotations: view.annotations.filter(one => one.id !== id) }
144}
145
146export function navigateAnnotation(view: ReviewView, delta: 1 | -1): ReviewView {
147 if (view.mode.kind !== 'browse' || view.annotations.length === 0) return view
148 const ordered = sortAnnotations(view.annotations)
149 const index = ordered.findIndex(one => one.id === view.activeId)
150 const base = index < 0 ? (delta > 0 ? -1 : 0) : index
151 const next = ordered[(base + delta + ordered.length) % ordered.length]!
152 return { ...view, activeId: next.id, cursor: next.target.kind === 'rows' ? next.target.start : view.cursor }
153}
154
155/**
156 * Sets a question's reply. Given `token`, only while the question still waits
157 * on that ask: an edit, a new ask or a new review drops a late answer.
158 */
159export function setReply(view: ReviewView, id: string, reply: Reply, token?: string): ReviewView {
160 return {
161 ...view,
162 annotations: view.annotations.map(one =>
163 one.id === id && (token === undefined || (one.reply?.state === 'pending' && one.reply.token === token))
164 ? { ...one, reply }
165 : one,
166 ),
167 }
168}
169
170/** The rows a target quotes. */
171export function targetRows(doc: ReviewDoc, target: ReviewTarget): Row[] {
172 if (target.kind === 'overall') return []
173 return doc.rows.slice(target.start, target.end + 1)
174}
175types/index.d.ts 71 lines1/** One line of the response under review. */
2export type Row = { text: string }
3
4/** The response the pane reviews, one row per source line. */
5export type ReviewDoc = { rows: Row[] }
6
7export type AnnotationKind = 'comment' | 'suggestion' | 'issue' | 'question'
8
9export type ReviewTarget = { kind: 'overall' } | { kind: 'rows'; start: number; end: number }
10
11/** Where a question's inline answer stands; the token names the ask, so a late answer to an older one is dropped. */
12export type Reply =
13 | { state: 'pending'; token: string }
14 | { state: 'answered'; text: string }
15 | { state: 'failed'; error: string }
16
17export type Annotation = {
18 id: string
19 target: ReviewTarget
20 kind: AnnotationKind
21 body: string
22 /** A question's answer; null until asked, and again once the question is edited. */
23 reply: Reply | null
24}
25
26export type ReviewDraft = {
27 kind: AnnotationKind
28 target: ReviewTarget
29 editingId?: string
30 /** The text typed so far, kept so a redraw or a resumed review does not lose it. */
31 body: string
32}
33
34export type ReviewMode =
35 | { kind: 'browse' }
36 | { kind: 'select'; anchor: number }
37 | { kind: 'draft'; draft: ReviewDraft }
38 /** The annotation preview takes most of the pane and the keys scroll it. */
39 | { kind: 'read'; previewOffset: number }
40 | { kind: 'confirmDiscard' }
41
42export type ReviewView = {
43 cursor: number
44 /** The first display row drawn. */
45 offset: number
46 mode: ReviewMode
47 annotations: Annotation[]
48 activeId?: string
49 /** Numbers the next annotation's id, so ids stay unique after a delete. */
50 nextId: number
51 /** A one-line message for the person, cleared by their next key. */
52 notice?: string
53}
54
55/** A /review-diff open in Neovim, or closed there with annotations it will reopen with. */
56export type NvimReview = {
57 /** Holds review.json, open.sh, events.jsonl, annotations.json and the socket. */
58 dir: string
59 root: string
60 title: string
61 /** Bytes of events.jsonl already handled, where a restarted follower picks up. */
62 offset: number
63 isOpen: boolean
64}
65
66declare module 'claude-code' {
67 interface PluginState {
68 'review-pane': { doc: ReviewDoc | null; view: ReviewView | null; nvim: NvimReview | null }
69 }
70}
71