SLOPSHOPPER

review-pane

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

newpanecommandtoastmodelprocess
v0.3.0no licenseupdated 2026-10-12ramtinJ95/claude-mods/plugins/review-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-pane
│ ┃ Review ✕ › fix the failing auth test and add an audit log call │ ┃ Response · 0 notes │ ┃ Run the command again or click here to use … ⏺ Read(src/auth.ts) │ ┃ › 1 I updated src/auth.ts to reject expire ⎿ Read 6 lines │ ┃ added an audit call. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /review-diff │ ┃ ⎿ review-pane: Could not create a temporary index: │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review
Response · 0 notes Run the command again or click here to use the keys › 1 I updated src/auth.ts to reject expired claims and added an audit call. j: down k: up d: page down u: page up g: top b: bottom n: keep v: range c: comment s: suggest i: issue a: ask o: overall h: prev note l: next note r: edit x: delete e: read y: finish q: close
README

review-pane

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.

Install

/plugin install review-pane --marketplace ramtinJ95/claude-mods

/review-diff needs Neovim 0.10 or newer on PATH.

Commands

  • /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.

Reviewing a diff in Neovim

/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.

CommandKeyAction
:Review comment<leader>rcComment on the line or visual range
:Review suggest<leader>rsSuggestion on the line or range
:Review issue<leader>riIssue on the line or range
:Review ask<leader>raAsk a question about the line or range
:Review overall<leader>roComment on the changes as a whole (one; empty removes it)
:Review edit / delete<leader>re / <leader>rxEdit or delete the annotation under the cursor
:Review show<leader>rvRead the annotation and its answer in full
:Review next / prev]r / [rNext or previous annotation
:Review list<leader>rlAll annotations in the location list
:Review files<leader>rfThe changes in the quickfix list
:Review finish<leader>ryPut the review in Claude Code's prompt and close Neovim
:Review discard<leader>rqDrop 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.

Reviewing a response in the pane

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.

KeyAction
j / kNext / previous line
d / uHalf a page down / up
g / bFirst / last line
vStart or end a range
c / s / iComment, suggestion or issue on the line or range
aAsk a question about the line or range
oComment on the response as a whole
h / lPrevious / next annotation
r / xEdit / delete the annotation under the cursor
eRead the annotation in full: j/k/d/u scroll it, e returns to the response
yFinish: put the review in the prompt
qClose; 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.

Questions

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.

Prepared review

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.

Source 8 files
hooks/register.tsx 652 lines
1import { 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}
652
hooks/diff.ts 141 lines
1/** 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}
141
hooks/format.ts 112 lines
1import 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}
112
hooks/layout.ts 153 lines
1const 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}
153
hooks/nvim.ts 99 lines
1import 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}
99
hooks/style.ts 292 lines
1import { 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}
292
hooks/model.ts 175 lines
1import 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}
175
types/index.d.ts 71 lines
1/** 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