Review design specs and implementation plans in a pane: move by block, pin comments, ask side questions, submit one review.

Review Claude's design docs and plans like a pull request, without leaving the terminal.
When Claude writes a spec or a plan and says "please review it", you usually scroll the file, then type a long reply that quotes the parts you mean. claude-doc-review opens the document in a pane beside the conversation instead. You step through it block by block, pin comments on single paragraphs, bullets or table rows, and ask side questions that never enter the main conversation. When you're done, every comment goes back to Claude as one review.

<sub>Recorded with asciinema in a real terminal, reviewing this repo's own DESIGN.md. To replay it at full fidelity, run asciinema play docs/demo.cast.</sub>
One line, from any shell:
claude plugin marketplace add revelationnow/claude-doc-review && claude plugin install doc-review@claude-doc-review
Or from inside Claude Code:
/plugin marketplace add revelationnow/claude-doc-review
/plugin install doc-review@claude-doc-review
Then start a new session, or run /reload-plugins in an open one. This works the same in the terminal, the desktop app and VS Code. The settings below all have defaults, so the "userConfig options not yet set" note after installing can be ignored.
To update or remove it later:
claude plugin marketplace update claude-doc-review && claude plugin update doc-review@claude-doc-review
claude plugin uninstall doc-review@claude-doc-review
Open any markdown file for review. Type @ to pick it with Claude Code's file completion instead of typing the path:
/doc-review @docs/superpowers/specs/2026-10-04-widgets-design.md
You can also skip the command. When a plugin such as superpowers writes a spec or plan and asks for a review, the pane opens by itself.
| Spots specs and plans | A Write or Edit to a path that matches the configured globs marks the file as a candidate. When Claude's turn ends with a review request or names the file, the pane opens. |
/doc-review [path] | Opens any markdown file, or the latest candidate when no path is given. |
| Blocks, not lines | Each heading, paragraph, list item (nested ones too), code fence, table and quote is a focus stop. |
| Tables and code never wrap | Tables are drawn as aligned grids and code as highlighted code, both cut at the pane's edge. A scrollbar under each wide block pans it sideways with the mouse, or use h and l. |
| Anchored comments | A comment remembers its heading path and opening text, so it survives revisions. If its passage is deleted, the comment is kept and marked orphaned. |
Side questions (a) | By default, answered by model.fork over the session's own transcript, so the model knows the conversation and the prefix is cached. Click via this conversation next to the field to send questions to sonnet, haiku or opus instead, which answer from the document alone. Each answer shows its output and cached token counts. In a fresh session, a forked question goes to the session model with the whole document attached. |
Explain (i) | A fresh small model (haiku by default) explains the passage in plain words. It sees only the passage and the document's title, so it costs a few hundred tokens. |
| Escalate | Under each answer: keep as comment, send to conversation (with the passage attached as hidden context), or dismiss. |
| Submit or approve | s sends every comment as one review. y sends your approval phrase, can fold unsent comments in as non-blocking notes, and closes the pane. |
| Live refresh and diff | When Claude edits the open file, the pane re-reads it and re-anchors comments. Changed blocks get a + in the margin, r jumps between them, v marks the revision viewed, and d shows the diff against the version you reviewed. |
| Persistence | Comments, answered questions and the last-reviewed text are saved per document across sessions, for the twelve most recently touched documents. /doc-review forget [path] clears one. |
| Find | f opens the find field. As you type, every match in the document is highlighted and the field previews the match Enter will jump to. Enter jumps there, panning a wide table or code block to bring the match into view. Then n and p step forward and back through the matches, as in less and vim. f reopens the field with the find in it; cancel keeps the old find. |
| Sticky bar | Once you scroll down, a bar with your position and the main actions stays pinned to the top of the pane. |
| Cycle and jump | m cycles through blocks that have comments. g and e jump to the top and end. |
Hotkeys work while the pane has the keyboard. It opens focused from /doc-review; otherwise click it or press ctrl+x tab.
| Key | Action |
|---|---|
j / k | Next / previous block |
g / e | Top / end |
h / l | Pan a wide table or code block left / right |
f | Find |
n / p | Next / previous match |
m | Next block with a comment or question |
Tab / Shift+Tab | Walk the blocks and their actions |
c, or Enter on the current marker | Comment |
a | Ask a side question |
i | Explain on a small model |
s | Submit all comments as one review |
y | Approve (closes the pane) |
r / d / v | Next revised block / toggle diff / mark revision viewed (shown only after a revision) |
q / Esc | Close the pane (comments are kept) |
The mouse works wherever the surface reports it: fullscreen terminal, the desktop app and VS Code.
·) to select it. Click the current marker (▶) to comment.comment · ask · explain in the gap under it. A bullet in a tight list has no gap, so its actions show at the right end of its last line instead. Showing them doesn't shift the layout.‹ ───━━━━━─── › 41–88/102. Click the track to jump there, or click ‹ and › to step a screen at a time.Set these in /config or under pluginConfigs.doc-review in settings.
| Option | Default | Meaning |
|---|---|---|
globs | superpowers specs and plans, docs/plans, docs/specs, *-design.md, *-plan.md, SPEC.md, PLAN.md | Comma-separated globs of documents that count |
offer | auto | auto opens the pane when a review is requested. toast only shows a toast and status line. off means /doc-review only. |
approvePhrase | Looks good, proceed. | What y sends as your words |
askModel | session | Where side questions go by default. session forks this conversation. Any model alias or id answers from the document alone. |
explainModel | haiku | The model alias or id behind i |
With auto, a pane that opens without a user action needs a terminal at least 144 columns wide (110 once you've opened it yourself). On a narrower terminal you get a toast pointing at /doc-review, which opens the pane at any width.
| Surface | Support |
|---|---|
| Terminal, fullscreen | Docked pane, keys and mouse |
| Terminal, main screen | Inline pane above the prompt, keys only |
| Desktop app (Code tab) | Docked pane, mouse, and the app's own text field and Markdown |
| VS Code | Same as the desktop app |
| Mobile | Comment and ask through the question dialog |
The marketplace install above covers every surface. To run a local checkout instead, for example while hacking on it, use one of:
claude --plugin-dir ./doc-review for a single terminal session.CLAUDE_CODE_PLUGIN_DIRS set to the folder's absolute path in the env block of ~/.claude/settings.json, for every session including desktop and VS Code. Add "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1" to hot-reload edits.claude plugin marketplace add ./ from the checkout, then install as above. A folder marketplace is read in place, so /reload-plugins picks up edits.claude plugin validate doc-review # what the engine will load and refuse
claude plugin test doc-review # 47 tests, no terminal needed
npx -p typescript@5 tsc -p doc-review
The engine's checks differ between Claude Code builds, so run validate and test with each build you support (~/.local/share/claude/versions/<build> plugin test doc-review) before publishing a version. A module one build refuses doesn't load at all there, and /doc-review disappears.
Type-checking needs the API declarations that the engine writes under doc-review/.claude-plugin/types/ the first time the mod loads in a session. The tests mount every view on the terminal, desktop and VS Code surfaces.
doc-review/
.claude-plugin/plugin.json manifest, userConfig, types contract
hooks/hooks.json names the hooks module
hooks/register.tsx the hooks: tool.call, turn.complete, command.run, ui.*
hooks/blocks.ts markdown to blocks; anchors and re-anchoring
hooks/diff.ts line diff, unified hunks, changed-block detection
hooks/persist.ts the shape of the per-document store record
hooks/review-prompt.ts the review, ask, escalation and approval texts
types/index.d.ts the state contract ($.state under 'doc-review')
tests/review.test.ts claude plugin test
.claude-plugin/marketplace.json makes the repo a plugin marketplace
docs/demo.gif, docs/demo.cast the recording above
DESIGN.md the design and the reasoning behind it
LICENSE Apache License 2.0
Two engine rules matter when you edit the mod:
$ is followed only into functions declared in the same file. A helper in another module can't take $, which is why persist.ts holds only shapes and the store calls live in register.tsx.Client module (phase 3 of the design). Button hotkeys already cover every key on every surface. A Client only receives keys after a click gives it focus, and only on terminal and desktop.Client and removed. Clicking a Client gives it the keyboard, so j, k and Tab stopped reaching the pane. The bar is now a row of Buttons, which can't trap the keys.hooks/register.tsx 1544 lines1// doc-review: review a design spec or implementation plan in a pane.
2//
3// Move block by block, pin comments, ask side questions the main conversation
4// never sees, then submit every comment as one review, or approve. Comments
5// persist across sessions, and a revision can be read as a diff against the
6// version last reviewed.
7
8import { atom, read, update } from 'claude-code'
9import type {
10 BoxProps,
11 ButtonProps,
12 CodeProps,
13 ElementConstructor,
14 EngineInterface,
15 InputProps,
16 MarkdownProps,
17 ModelForkResult,
18 Register,
19 RenderElement,
20 TextProps,
21} from 'claude-code'
22
23import type {
24 DocReviewBlock,
25 DocReviewCandidate,
26 DocReviewComment,
27 DocReviewComposer,
28 DocReviewDoc,
29 DocReviewThread,
30} from '../types'
31import { anchorFor, basename, cleanText, codeParts, excerpt, parseBlocks, plainText, reanchor, titleOf, unwrappedLines } from './blocks'
32import { changedBlocks, diffText } from './diff'
33import { STORE_PREFIX, isSaved, keysToEvict, storeKey, toSaved } from './persist'
34import { buildApprovalPrompt, buildAskPrompt, buildEscalationPrompt, buildExplainPrompt, buildReviewPrompt, buildStandaloneAskPrompt } from './review-prompt'
35
36const PLUGIN = 'doc-review'
37const PANE = 'doc-review'
38// Not `review`: Claude Code has a built-in /review for pull requests.
39const COMMAND = 'doc-review'
40
41const DEFAULT_GLOBS =
42 'docs/superpowers/specs/**/*.md,docs/superpowers/plans/**/*.md,docs/plans/**/*.md,docs/specs/**/*.md,**/*-design.md,**/*-plan.md,SPEC.md,PLAN.md'
43const REVIEW_ASKED = /please (review|take a look)|review (it|the (plan|spec|design|document))|let me know if you want (to make )?(any )?changes/i
44const MARKDOWN_CAP = 9000
45const CODE_CAP = 9000
46const MAX_HUNKS = 40
47const CANDIDATE_TURNS = 2
48// Below this many columns the pane draws compact: a narrower gutter, the path on its own line.
49const NARROW = 70
50// Up to this many blocks the pane draws them all, so the wheel reaches every
51// one; a longer document draws a window of WINDOW_HALF either side of the cursor.
52const MAX_DRAWN = 200
53/** Rows the sticky bar takes at the top of a scrolled pane: status, actions, rule. */
54const BAR_ROWS = 3
55const WINDOW_HALF = 60
56
57const docA = atom({ plugin: 'doc-review', key: 'doc' } as const, null)
58const commentsA = atom({ plugin: 'doc-review', key: 'comments' } as const, [])
59const threadsA = atom({ plugin: 'doc-review', key: 'threads' } as const, [])
60const composerA = atom({ plugin: 'doc-review', key: 'composer' } as const, null)
61const candidatesA = atom({ plugin: 'doc-review', key: 'candidates' } as const, [])
62const offeredA = atom({ plugin: 'doc-review', key: 'offered' } as const, [])
63const noticeA = atom({ plugin: 'doc-review', key: 'notice' } as const, null)
64const askViaA = atom({ plugin: 'doc-review', key: 'askVia' } as const, null)
65
66/** Ask through a fork of the conversation: the session's model, with the transcript cached. */
67const VIA_SESSION = 'session'
68
69/** What the ask composer's model button cycles through, the configured default first. */
70function askChoices(configured: string): string[] {
71 return [...new Set([configured, VIA_SESSION, 'sonnet', 'haiku', 'opus'])]
72}
73
74function viaLabel(via: string): string {
75 return via === VIA_SESSION ? 'via this conversation' : `via ${via}`
76}
77
78/** A path as typed, or as an @-mention completed it (`@path`, `@"path with spaces"`). */
79function argPath(raw: string): string {
80 const s = raw.trim()
81 const m = /^@"(.*)"$/.exec(s) ?? /^@(.*)$/.exec(s)
82 return (m ? m[1]! : s).trim()
83}
84
85type Table = {
86 Box: ElementConstructor<BoxProps>
87 Text: ElementConstructor<TextProps>
88 Button: ElementConstructor<ButtonProps>
89 Markdown: ElementConstructor<MarkdownProps>
90 Code: ElementConstructor<CodeProps>
91}
92
93// ---- globs -----------------------------------------------------------------
94
95export function globToRegExp(glob: string): RegExp {
96 let re = '^'
97 for (let i = 0; i < glob.length; i += 1) {
98 const c = glob[i] ?? ''
99 if (c === '*') {
100 if (glob[i + 1] === '*') {
101 i += 1
102 if (glob[i + 1] === '/') {
103 i += 1
104 re += '(?:.*/)?'
105 } else {
106 re += '.*'
107 }
108 } else {
109 re += '[^/]*'
110 }
111 } else if (c === '?') {
112 re += '[^/]'
113 } else if ('.+^${}()|[]\\'.includes(c)) {
114 re += `\\${c}`
115 } else {
116 re += c
117 }
118 }
119 return new RegExp(`${re}$`)
120}
121
122function relativeTo(cwd: string, path: string): string {
123 const p = path.replace(/\\/g, '/')
124 const base = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
125 if (base !== '' && p.startsWith(`${base}/`)) return p.slice(base.length + 1)
126 return p.replace(/^\.\//, '')
127}
128
129function samePath(cwd: string, a: string, b: string): boolean {
130 return relativeTo(cwd, a) === relativeTo(cwd, b)
131}
132
133// ---- document --------------------------------------------------------------
134
135async function readText($: EngineInterface, path: string): Promise<string> {
136 const raw = await $.fs.read(path)
137 return cleanText(typeof raw === 'string' ? raw : '')
138}
139
140function withBaseline(doc: Omit<DocReviewDoc, 'changed'>, baselineText: string): DocReviewDoc {
141 const changed = baselineText === doc.text ? [] : changedBlocks(doc.blocks, parseBlocks(baselineText))
142 return { ...doc, baselineText, changed }
143}
144
145async function reanchorAll($: EngineInterface, blocks: readonly DocReviewBlock[]): Promise<void> {
146 await update($, commentsA, list =>
147 list.map(c => {
148 const at = reanchor(c.anchor, blocks)
149 return at === -1 ? { ...c, isOrphan: true } : { ...c, isOrphan: false, anchor: { ...c.anchor, blockIndex: at } }
150 }),
151 )
152 await update($, threadsA, list =>
153 list.map(t => {
154 const at = reanchor(t.anchor, blocks)
155 return at === -1 ? t : { ...t, anchor: { ...t.anchor, blockIndex: at } }
156 }),
157 )
158}
159
160async function loadSaved($: EngineInterface, key: string) {
161 const value = await $.store.get(key)
162 return isSaved(value) ? value : null
163}
164
165/** Writes the open document's comments, questions and baseline to the store. */
166async function persist($: EngineInterface): Promise<void> {
167 const doc = await read($, docA)
168 if (!doc) return
169 const cwd = await $.session.cwd()
170 const key = storeKey(cwd, doc.path)
171 const saved = toSaved(
172 {
173 path: doc.path,
174 comments: await read($, commentsA),
175 threads: await read($, threadsA),
176 baselineText: doc.baselineText,
177 lastReviewAt: doc.lastReviewAt,
178 },
179 await $.clock.now(),
180 )
181 await $.store.set(key, saved)
182
183 // Keep the store bounded: drop the records of the least recently touched documents.
184 const others = (await $.store.keys()).filter(k => k.startsWith(STORE_PREFIX) && k !== key)
185 if (others.length >= 12) {
186 const aged: { key: string; updatedAt: number }[] = []
187 for (const other of others) {
188 const value = await $.store.get(other)
189 aged.push({ key: other, updatedAt: isSaved(value) ? value.updatedAt : 0 })
190 }
191 for (const old of keysToEvict(aged)) await $.store.delete(old)
192 }
193}
194
195/** Opens `path` in the pane. `asked` is true when a person's action is behind it. */
196async function openDoc($: EngineInterface, path: string, asked: boolean) {
197 const cwd = await $.session.cwd()
198 const previous = await read($, docA)
199 const isSame = previous !== null && samePath(cwd, previous.path, path)
200 const text = await readText($, path)
201 const blocks = parseBlocks(text)
202 let notice: string | null = null
203
204 if (isSame) {
205 const doc = withBaseline(
206 {
207 ...previous,
208 text,
209 blocks,
210 title: titleOf(blocks, path),
211 cursor: Math.min(previous.cursor, Math.max(0, blocks.length - 1)),
212 revision: previous.text === text ? previous.revision : previous.revision + 1,
213 view: 'document',
214 awaitingRevision: previous.text === text ? previous.awaitingRevision : false,
215 search: null,
216 },
217 previous.baselineText,
218 )
219 await update($, docA, () => doc)
220 await reanchorAll($, blocks)
221 } else {
222 const saved = await loadSaved($, storeKey(cwd, path))
223 const doc = withBaseline(
224 {
225 path,
226 title: titleOf(blocks, path),
227 text,
228 blocks,
229 cursor: 0,
230 revision: 0,
231 baselineText: saved?.baselineText ?? text,
232 view: 'document',
233 awaitingRevision: false,
234 lastReviewAt: saved?.lastReviewAt ?? null,
235 search: null,
236 },
237 saved?.baselineText ?? text,
238 )
239 await update($, docA, () => doc)
240 await update($, commentsA, () => (saved ? [...saved.comments] : []))
241 await update($, threadsA, () => (saved ? [...saved.threads] : []))
242 await reanchorAll($, blocks)
243 if (saved && (saved.comments.length > 0 || saved.threads.length > 0)) {
244 const parts: string[] = []
245 if (saved.comments.length > 0) parts.push(`${saved.comments.length} ${saved.comments.length === 1 ? 'comment' : 'comments'}`)
246 if (saved.threads.length > 0) parts.push(`${saved.threads.length} ${saved.threads.length === 1 ? 'question' : 'questions'}`)
247 notice = `Restored ${parts.join(' and ')} from an earlier session.`
248 }
249 if (doc.changed.length > 0) {
250 notice = `${notice ? `${notice} ` : ''}The file changed since you last reviewed it: ${doc.changed.length} ${doc.changed.length === 1 ? 'block differs' : 'blocks differ'} (d for the diff).`
251 }
252 }
253 await update($, composerA, () => null)
254 await update($, noticeA, () => notice)
255 await persist($)
256
257 const opened = await $.ui.open({
258 id: PANE,
259 title: `Review: ${basename(path)}`,
260 rows: 28,
261 ...(asked ? { focus: true as const } : {}),
262 })
263 if (opened.isPlaced) $.ui.status(undefined)
264 return opened
265}
266
267async function refreshDoc($: EngineInterface): Promise<void> {
268 const doc = await read($, docA)
269 if (!doc) return
270 const exists = await $.fs.exists(doc.path)
271 if (!exists) {
272 await update($, noticeA, () => 'The file was removed.')
273 return
274 }
275 const text = await readText($, doc.path)
276 if (text === doc.text) return
277 const blocks = parseBlocks(text)
278 const next = withBaseline(
279 {
280 ...doc,
281 text,
282 blocks,
283 title: titleOf(blocks, doc.path),
284 cursor: Math.min(doc.cursor, Math.max(0, blocks.length - 1)),
285 revision: doc.revision + 1,
286 awaitingRevision: false,
287 search: doc.search ? { query: doc.search.query, matches: findMatches(blocks, doc.search.query) } : null,
288 },
289 doc.baselineText,
290 )
291 await update($, docA, () => next)
292 await reanchorAll($, blocks)
293 const n = next.changed.length
294 await update($, noticeA, () =>
295 n > 0
296 ? `Revision ${next.revision}: ${n} ${n === 1 ? 'block differs' : 'blocks differ'} from the version you reviewed (d for the diff, n to jump).`
297 : `Revision ${next.revision}: nothing differs from the version you reviewed.`,
298 )
299 await persist($)
300}
301
302function latest(list: readonly DocReviewCandidate[]): DocReviewCandidate | undefined {
303 return [...list].sort((a, b) => b.writtenAt - a.writtenAt)[0]
304}
305
306function newId(prefix: string): string {
307 return `${prefix}-${Math.random().toString(36).slice(2, 10)}`
308}
309
310// ---- actions ---------------------------------------------------------------
311
312/**
313 * Makes a block current and scrolls to it. `pan` sets how far a wide block
314 * sits sideways (0 by default); `focus: false` leaves the keyboard where it
315 * is, as the find field needs while the person types.
316 */
317async function setCursor($: EngineInterface, cursor: number, opts: { pan?: number; focus?: boolean } = {}): Promise<void> {
318 // Not `pan`: the engine follows $ by function name, and a second
319 // declaration of a name it follows refuses the whole module.
320 const sideways = opts.pan ?? 0
321 let from = cursor
322 await update($, docA, d => {
323 if (d) from = d.cursor
324 return d && (d.cursor !== cursor || (d.pan ?? 0) !== sideways) ? { ...d, cursor, view: 'document' as const, pan: sideways } : d
325 })
326 // Going up, the block lands on the window's first row, where the sticky bar
327 // covers it: reveal the mark drawn the bar's height above it instead.
328 // The first block goes back to the very top, header and all.
329 const to = cursor === 0 ? 'start' : { key: cursor < from ? `pre:${cursor}` : `blk:${cursor}` }
330 void $.ui.scroll({ to, in: PANE, block: 'nearest' }).catch(() => undefined)
331 if (opts.focus !== false) void $.ui.focus({ requestId: PANE, key: `b:${cursor}` }).catch(() => undefined)
332}
333
334async function moveCursor($: EngineInterface, delta: number | ((cursor: number, n: number) => number)): Promise<void> {
335 const doc = await read($, docA)
336 if (!doc || doc.blocks.length === 0) return
337 const n = doc.blocks.length
338 const target = typeof delta === 'number' ? doc.cursor + delta : delta(doc.cursor, n)
339 await setCursor($, Math.max(0, Math.min(n - 1, target)))
340}
341
342/** Moves to the next changed block after the cursor, wrapping around. */
343async function nextChange($: EngineInterface): Promise<void> {
344 const doc = await read($, docA)
345 if (!doc) return
346 if (doc.changed.length === 0) {
347 $.ui.toast('doc-review: nothing differs from the version you reviewed.')
348 return
349 }
350 const after = doc.changed.find(i => i > doc.cursor)
351 await setCursor($, after ?? doc.changed[0] ?? doc.cursor)
352}
353
354function findMatches(blocks: readonly DocReviewBlock[], query: string): number[] {
355 const q = query.trim().toLowerCase()
356 if (q === '') return []
357 return blocks.filter(b => plainText(b.text).toLowerCase().includes(q)).map(b => b.index)
358}
359
360/** The columns the pane body last drew at: what a find needs to pan a wide block to its match. */
361let lastColumns = 100
362
363/** Where each occurrence of `query` sits in `text`, as [start, end) in code points, case-insensitive. */
364function occurrences(text: string, query: string): [number, number][] {
365 const q = query.toLowerCase()
366 if (q === '') return []
367 const lower = text.toLowerCase()
368 const out: [number, number][] = []
369 for (let at = lower.indexOf(q); at >= 0; at = lower.indexOf(q, at + q.length)) {
370 const from = [...text.slice(0, at)].length
371 out.push([from, from + [...text.slice(at, at + q.length)].length])
372 }
373 return out
374}
375
376/** How far to pan a table or code block so the first occurrence of `query` shows: 0 when it already does. */
377function panToMatch(block: DocReviewBlock, query: string): number {
378 const lines = unwrappedLines(block)
379 if (!lines) return 0
380 const gutter = lastColumns < NARROW ? 2 : 3
381 const room = Math.max(10, lastColumns - gutter - (block.depth ?? 0) * 2)
382 const { max } = panRange(block, room)
383 for (const line of lines) {
384 const hit = occurrences(line, query)[0]
385 if (!hit) continue
386 if (hit[1] <= room) return 0
387 return Math.max(0, Math.min(max, hit[0] - Math.floor(room / 3)))
388 }
389 return 0
390}
391
392/** Moves to match `at` of the find: pans a wide block to it. */
393async function landOnMatch($: EngineInterface, doc: DocReviewDoc, at: number, query: string, focus = true): Promise<void> {
394 const block = doc.blocks[at]
395 await setCursor($, at, { pan: block ? panToMatch(block, query) : 0, focus })
396}
397
398/** Enter in the find field: keeps the find and its match, closes the field. */
399async function runFind($: EngineInterface, query: string): Promise<void> {
400 const doc = await read($, docA)
401 if (!doc) return
402 const composer = await read($, composerA)
403 const origin = composer?.mode === 'find' ? composer.blockIndex : doc.cursor
404 const q = query.trim()
405 const matches = findMatches(doc.blocks, q)
406 await update($, docA, d => (d ? { ...d, search: q === '' ? null : { query: q, matches } } : d))
407 await update($, composerA, () => null)
408 if (q === '') return
409 if (matches.length === 0) {
410 $.ui.toast(`doc-review: no block contains "${q}".`)
411 await setCursor($, origin)
412 return
413 }
414 await landOnMatch($, doc, nextMatchFrom(matches, origin) ?? origin, q)
415}
416
417/**
418 * Each keystroke in the find field: the matches and their highlights update,
419 * the cursor stays. A jump would scroll the field out of the window, and a
420 * focused field scrolled out of the window loses the keyboard; Enter jumps.
421 */
422async function findAsYouType($: EngineInterface, query: string): Promise<void> {
423 const doc = await read($, docA)
424 const composer = await read($, composerA)
425 if (!doc || composer?.mode !== 'find') return
426 const q = query.trim()
427 const matches = findMatches(doc.blocks, q)
428 await update($, docA, d => (d ? { ...d, search: q === '' ? null : { query: q, matches } } : d))
429}
430
431/** The match Enter in the find field goes to: the first at or after where find began. */
432function nextMatchFrom(matches: readonly number[], origin: number): number | undefined {
433 return matches.find(i => i >= origin) ?? matches[0]
434}
435
436/** `f`: opens the find field, holding the current find's text to edit or replace. */
437async function openFind($: EngineInterface): Promise<void> {
438 const doc = await read($, docA)
439 if (!doc) return
440 await update($, docA, d => (d ? { ...d, view: 'document' as const } : d))
441 await update($, composerA, () => ({ blockIndex: doc.cursor, mode: 'find' as const, initial: doc.search?.query ?? '' }))
442 void $.ui.focus({ requestId: PANE, key: 'find-field' }).catch(() => undefined)
443}
444
445/** `n`: the next match, wrapping; with no find yet, opens the field. */
446async function findNext($: EngineInterface): Promise<void> {
447 const doc = await read($, docA)
448 if (!doc) return
449 if (!doc.search || doc.search.matches.length === 0) return openFind($)
450 const { matches, query } = doc.search
451 await landOnMatch($, doc, matches.find(i => i > doc.cursor) ?? matches[0] ?? doc.cursor, query)
452}
453
454/** `p`: the previous match, wrapping. */
455async function findPrev($: EngineInterface): Promise<void> {
456 const doc = await read($, docA)
457 if (!doc?.search || doc.search.matches.length === 0) return
458 const { matches, query } = doc.search
459 await landOnMatch($, doc, [...matches].reverse().find(i => i < doc.cursor) ?? matches[matches.length - 1] ?? doc.cursor, query)
460}
461
462async function clearFind($: EngineInterface): Promise<void> {
463 await update($, docA, d => (d ? { ...d, search: null } : d))
464 await update($, composerA, c => (c?.mode === 'find' ? null : c))
465}
466
467/** The find field's cancel: the find held before it opened stays, or none; the cursor never left. */
468async function cancelFind($: EngineInterface): Promise<void> {
469 const composer = await read($, composerA)
470 const before = composer?.mode === 'find' ? (composer.initial ?? '').trim() : ''
471 if (before === '') await clearFind($)
472 else {
473 // The find held before the field opened comes back, as typed edits are dropped.
474 await update($, docA, d => (d ? { ...d, search: { query: before, matches: findMatches(d.blocks, before) } } : d))
475 await update($, composerA, () => null)
476 }
477 if (composer?.mode === 'find') await setCursor($, composer.blockIndex)
478}
479
480/** `m`: the next block after the cursor that carries a comment or a question, wrapping. */
481async function nextComment($: EngineInterface): Promise<void> {
482 const doc = await read($, docA)
483 if (!doc) return
484 const comments = await read($, commentsA)
485 const threads = await read($, threadsA)
486 const marked = [...new Set([...comments.filter(c => !c.isOrphan).map(c => c.anchor.blockIndex), ...threads.map(t => t.anchor.blockIndex)])].sort((a, b) => a - b)
487 if (marked.length === 0) {
488 $.ui.toast('doc-review: no comments or questions yet.')
489 return
490 }
491 const after = marked.find(i => i > doc.cursor)
492 await setCursor($, after ?? marked[0] ?? doc.cursor)
493}
494
495/** `i`: a plain-words explanation of a block (the current one by default) from a small, fresh model. */
496async function explain($: EngineInterface, model: string, blockIndex?: number): Promise<void> {
497 const doc = await read($, docA)
498 if (!doc) return
499 const at = blockIndex ?? doc.cursor
500 const block = doc.blocks[at]
501 if (!block) return
502 if (at !== doc.cursor) await update($, docA, d => (d ? { ...d, cursor: at, pan: 0 } : d))
503 const thread: DocReviewThread = {
504 id: newId('x'),
505 anchor: anchorFor(block),
506 kind: 'explain',
507 question: 'Explain this passage',
508 status: 'pending',
509 model,
510 }
511 await update($, threadsA, list => [...list, thread])
512 await update($, composerA, () => null)
513
514 const { system, prompt } = buildExplainPrompt({ path: doc.path, title: doc.title, block })
515 const reply = await $.model.complete({ model, system, prompt, effort: 'low', maxTokens: 400, timeoutMs: 30000 })
516 await update($, threadsA, list =>
517 list.map((t): DocReviewThread => {
518 if (t.id !== thread.id) return t
519 if (reply.isAnswered) {
520 return { ...t, status: 'answered', answer: cleanText(reply.text).trim(), outputTokens: reply.usage.output_tokens, cachedTokens: reply.usage.cache_read_input_tokens }
521 }
522 const why = reply.reason === 'api-error' ? `API error${reply.status ? ` ${reply.status}` : ''} (${reply.error})` : reply.reason
523 return { ...t, status: 'failed', failure: why }
524 }),
525 )
526 await persist($)
527}
528
529/** Drops the document's saved comments and questions, in the pane and the store. */
530async function forgetDoc($: EngineInterface, path: string): Promise<void> {
531 const cwd = await $.session.cwd()
532 await $.store.delete(storeKey(cwd, path))
533 const doc = await read($, docA)
534 if (doc && samePath(cwd, doc.path, path)) {
535 await update($, commentsA, () => [])
536 await update($, threadsA, () => [])
537 await update($, docA, d => (d ? { ...withBaseline(d, d.text), awaitingRevision: false, lastReviewAt: null } : d))
538 await update($, noticeA, () => 'Saved comments and questions for this document were cleared.')
539 }
540}
541
542async function toggleView($: EngineInterface): Promise<void> {
543 await update($, docA, d => (d ? { ...d, view: d.view === 'diff' ? ('document' as const) : ('diff' as const) } : d))
544 await update($, composerA, () => null)
545 void $.ui.scroll({ to: 'start', in: PANE }).catch(() => undefined)
546}
547
548/**
549 * Scrolls block `at`'s table or code sideways, within 0 to `max`, and makes it
550 * the current block: by `by` columns from where it is, or to column `to`.
551 * With `cycle` (the `p` key) a move past the right edge goes back to the left.
552 */
553async function pan(
554 $: EngineInterface,
555 at: number,
556 move: { by: number; cycle?: boolean } | { to: number },
557 max: number,
558): Promise<void> {
559 await update($, docA, d => {
560 if (!d) return d
561 const from = d.cursor === at ? (d.pan ?? 0) : 0
562 const to =
563 'to' in move
564 ? Math.max(0, Math.min(max, move.to))
565 : move.cycle && from >= max
566 ? 0
567 : Math.max(0, Math.min(max, from + move.by))
568 return d.cursor === at && to === from && d.view === 'document' ? d : { ...d, cursor: at, view: 'document' as const, pan: to }
569 })
570}
571
572/** How far a table or code block can scroll sideways in `room` columns, and by how much a step. */
573function panRange(block: DocReviewBlock, room: number): { max: number; step: number } {
574 return { max: Math.max(0, unwrappedWidth(block) - room), step: Math.max(8, room - 8) }
575}
576
577/** The blocks the pane draws: all of them, or a window around the cursor in a long document. */
578function windowOf(n: number, cursor: number): { lo: number; hi: number } {
579 if (n <= MAX_DRAWN) return { lo: 0, hi: n }
580 const size = WINDOW_HALF * 2 + 1
581 const lo = Math.max(0, Math.min(cursor - WINDOW_HALF, n - size))
582 return { lo, hi: Math.min(n, lo + size) }
583}
584
585/** Takes the current text as the version reviewed: the diff empties. */
586async function markReviewed($: EngineInterface): Promise<void> {
587 const doc = await read($, docA)
588 if (!doc) return
589 await update($, docA, d => (d ? { ...withBaseline(d, d.text), view: 'document' as const, awaitingRevision: false } : d))
590 await update($, noticeA, () => `Revision ${doc.revision} marked as reviewed.`)
591 await persist($)
592}
593
594async function openComposer($: EngineInterface, mode: 'comment' | 'ask', blockIndex?: number): Promise<void> {
595 const doc = await read($, docA)
596 if (!doc || doc.blocks.length === 0) return
597 const at = blockIndex ?? doc.cursor
598 if (at !== doc.cursor || doc.view !== 'document') await update($, docA, d => (d ? { ...d, cursor: at, view: 'document' as const } : d))
599 await update($, composerA, () => ({ blockIndex: at, mode }))
600 void $.ui.scroll({ to: { key: `blk:${at}` }, in: PANE, block: 'nearest' }).catch(() => undefined)
601 void $.ui.focus({ requestId: PANE, key: 'compose' }).catch(() => undefined)
602}
603
604/**
605 * The composer for a surface with no text field (mobile): the engine's own
606 * dialog, whose "Other" takes free text.
607 */
608async function composeViaDialog($: EngineInterface, mode: 'comment' | 'ask', via: string, blockIndex?: number): Promise<void> {
609 const doc = await read($, docA)
610 if (!doc || doc.blocks.length === 0) return
611 const at = blockIndex ?? doc.cursor
612 const block = doc.blocks[at]
613 if (!block) return
614 if (at !== doc.cursor) await update($, docA, d => (d ? { ...d, cursor: at } : d))
615
616 const other = mode === 'ask' ? 'Comment instead' : 'Ask instead'
617 let answer: string
618 try {
619 answer = await $.ui.ask(`About "${excerpt(block, 80)}": what is your ${mode === 'ask' ? 'question' : 'comment'}?`, {
620 options: ['Cancel', other],
621 header: mode === 'ask' ? 'Ask' : 'Comment',
622 })
623 } catch {
624 return
625 }
626 if (answer === 'Cancel' || answer.trim() === '') return
627 if (answer === other) {
628 await composeViaDialog($, mode === 'ask' ? 'comment' : 'ask', via, at)
629 return
630 }
631 if (mode === 'ask') await ask($, doc, block, answer.trim(), via)
632 else await addComment($, block, answer.trim())
633}
634
635async function addComment($: EngineInterface, block: DocReviewBlock, text: string): Promise<void> {
636 const comment: DocReviewComment = { id: newId('c'), anchor: anchorFor(block), text, isOrphan: false }
637 await update($, commentsA, list => [...list, comment])
638 await update($, composerA, () => null)
639 await persist($)
640}
641
642async function removeComment($: EngineInterface, id: string): Promise<void> {
643 await update($, commentsA, list => list.filter(x => x.id !== id))
644 await persist($)
645}
646
647async function dismissThread($: EngineInterface, id: string): Promise<void> {
648 await update($, threadsA, list => list.filter(x => x.id !== id))
649 await persist($)
650}
651
652async function ask($: EngineInterface, doc: DocReviewDoc, block: DocReviewBlock, question: string, via: string): Promise<void> {
653 const thread: DocReviewThread = { id: newId('t'), anchor: anchorFor(block), kind: 'ask', question, status: 'pending' }
654 await update($, threadsA, list => [...list, thread])
655 await update($, composerA, () => null)
656
657 let reply: ModelForkResult
658 let standaloneModel: string | undefined
659 let alone: 'no-reply' | 'chosen' | undefined
660 const standalone = async (model: string): Promise<ModelForkResult> => {
661 const { system, prompt } = buildStandaloneAskPrompt({ path: doc.path, title: doc.title, text: doc.text, block, question })
662 return $.model.complete({ model, system, prompt, maxTokens: 800, timeoutMs: 90000 })
663 }
664
665 if (via === VIA_SESSION) {
666 // Over the conversation's own transcript first: the model already has the
667 // document in context and the prefix is cached.
668 reply = await $.model.fork({ prompt: buildAskPrompt({ path: doc.path, block, question }) })
669 // A fresh session, or one just cleared, has no reply to fork from. Then ask
670 // the session's model directly, with the whole document attached.
671 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
672 standaloneModel = await $.session.model()
673 alone = 'no-reply'
674 reply = await standalone(standaloneModel)
675 }
676 } else {
677 // A fork always runs on the session's model, so another model gets the
678 // document alone: no conversation, no cached prefix.
679 standaloneModel = via
680 alone = 'chosen'
681 reply = await standalone(via)
682 }
683
684 await update($, threadsA, list =>
685 list.map((t): DocReviewThread => {
686 if (t.id !== thread.id) return t
687 if (reply.isAnswered) {
688 return {
689 ...t,
690 status: 'answered',
691 answer: cleanText(reply.text).trim(),
692 outputTokens: reply.usage.output_tokens,
693 cachedTokens: reply.usage.cache_read_input_tokens,
694 ...(standaloneModel ? { model: standaloneModel } : {}),
695 ...(alone ? { alone } : {}),
696 }
697 }
698 const why = reply.reason === 'api-error' ? `API error${reply.status ? ` ${reply.status}` : ''} (${reply.error})` : reply.reason
699 return { ...t, status: 'failed', failure: why }
700 }),
701 )
702 await persist($)
703}
704
705async function submitReview($: EngineInterface): Promise<void> {
706 const doc = await read($, docA)
707 const comments = await read($, commentsA)
708 if (!doc) return
709 if (comments.length === 0) {
710 $.ui.toast('doc-review: no comments yet. Press c on a block to add one.')
711 return
712 }
713 const text = buildReviewPrompt({ path: doc.path, comments, blocks: doc.blocks })
714 const sent = await $.prompt.submit({ text, asUser: true })
715 if (sent.drop !== undefined) {
716 await update($, noticeA, () => `Review not sent: ${sent.drop}`)
717 return
718 }
719 const now = await $.clock.now()
720 await update($, commentsA, () => [])
721 // The version reviewed is the one the comments were made on: the next
722 // revision diffs against it.
723 await update($, docA, d => (d ? { ...withBaseline(d, d.text), awaitingRevision: true, lastReviewAt: now } : d))
724 await update($, noticeA, () => `Review sent with ${comments.length} ${comments.length === 1 ? 'comment' : 'comments'}. The pane refreshes when the file changes.`)
725 await persist($)
726}
727
728async function approve($: EngineInterface, phrase: string): Promise<void> {
729 const doc = await read($, docA)
730 const comments = await read($, commentsA)
731 if (!doc) return
732 let notes: DocReviewComment[] = []
733 if (comments.length > 0) {
734 let choice: string
735 try {
736 choice = await $.ui.ask(`You have ${comments.length} unsent ${comments.length === 1 ? 'comment' : 'comments'}. Include them with the approval?`, [
737 'Include as non-blocking notes',
738 'Approve only',
739 'Cancel',
740 ])
741 } catch {
742 return
743 }
744 if (choice === 'Cancel') return
745 if (choice === 'Include as non-blocking notes') notes = [...comments]
746 }
747 const text = buildApprovalPrompt({ path: doc.path, phrase, notes, blocks: doc.blocks })
748 const sent = await $.prompt.submit({ text, asUser: true })
749 if (sent.drop !== undefined) {
750 await update($, noticeA, () => `Approval not sent: ${sent.drop}`)
751 return
752 }
753 const now = await $.clock.now()
754 await update($, commentsA, () => [])
755 await update($, docA, d => (d ? { ...withBaseline(d, d.text), awaitingRevision: false, lastReviewAt: now } : d))
756 await update($, noticeA, () => 'Approval sent.')
757 await persist($)
758 // The review is over: the pane makes way for the conversation.
759 await $.ui.close({ id: PANE })
760}
761
762async function escalate($: EngineInterface, thread: DocReviewThread): Promise<void> {
763 const doc = await read($, docA)
764 if (!doc) return
765 const at = reanchor(thread.anchor, doc.blocks)
766 const block = at === -1 ? undefined : doc.blocks[at]
767 if (!block) return
768 const text = buildEscalationPrompt({ path: doc.path, block, question: thread.question, answer: thread.answer })
769 const sent = await $.prompt.submit({ text, asUser: true })
770 if (sent.drop !== undefined) {
771 await update($, noticeA, () => `Not sent: ${sent.drop}`)
772 return
773 }
774 await update($, threadsA, list => list.filter(t => t.id !== thread.id))
775 await update($, noticeA, () => 'Question sent to the conversation.')
776 await persist($)
777}
778
779async function keepAsComment($: EngineInterface, thread: DocReviewThread): Promise<void> {
780 const answer = thread.answer ? ` (your side answer was: "${thread.answer.slice(0, 300)}")` : ''
781 const comment: DocReviewComment = {
782 id: newId('c'),
783 anchor: thread.anchor,
784 text: `${thread.question}${answer}`,
785 isOrphan: false,
786 }
787 await update($, commentsA, list => [...list, comment])
788 await update($, threadsA, list => list.filter(t => t.id !== thread.id))
789 await persist($)
790}
791
792// ---- drawing ---------------------------------------------------------------
793
794function capMarkdown(text: string): string {
795 return text.length > MARKDOWN_CAP ? `${text.slice(0, MARKDOWN_CAP)}\n\n_…block truncated for display…_` : text
796}
797
798/** Cut on a line under the element's limit, as a hunk is. */
799function capCode(source: string): string {
800 if (source.length <= CODE_CAP) return source
801 const cut = source.lastIndexOf('\n', CODE_CAP)
802 return source.slice(0, cut > 0 ? cut : CODE_CAP)
803}
804
805function widthOf(line: string): number {
806 return [...line].length
807}
808
809/** How wide a table or code block draws unwrapped; 0 for a block that wraps. */
810function unwrappedWidth(block: DocReviewBlock): number {
811 const lines = unwrappedLines(block)
812 return lines ? Math.max(0, ...lines.map(widthOf)) : 0
813}
814
815/**
816 * One block's content. Prose wraps as Markdown draws it; a table (as an
817 * aligned grid) and a code block never wrap: a line wider than `room` is cut
818 * at the edge, and `pan` columns scroll it right.
819 */
820/**
821 * `text` from code point `from` on, as Text runs with every occurrence of
822 * `query` inverted. Never empty: a line panned past its end is one space.
823 */
824function marked(t: Table, id: string, text: string, query: string, from = 0): (string | RenderElement)[] {
825 const { Text } = t
826 const chars = [...text]
827 const out: (string | RenderElement)[] = []
828 let at = from
829 for (const [a, b] of occurrences(text, query)) {
830 if (b <= from) continue
831 const start = Math.max(a, from)
832 if (start > at) out.push(chars.slice(at, start).join(''))
833 out.push(
834 <Text key={`hit:${id}:${start}`} inverse>
835 {chars.slice(start, b).join('')}
836 </Text>,
837 )
838 at = b
839 }
840 if (at < chars.length) out.push(chars.slice(at).join(''))
841 return out.length > 0 ? out : [' ']
842}
843
844function drawBody(
845 $: EngineInterface,
846 t: Table,
847 block: DocReviewBlock,
848 args: { room: number; pan: number; dim: boolean; isCurrent: boolean; query?: string },
849): RenderElement {
850 const { Box, Text, Button, Markdown, Code } = t
851 const lines = unwrappedLines(block)
852 // Markdown has no way to mark a span, so a block holding the find's text is
853 // drawn as plain text with each occurrence inverted, while the find lasts.
854 if (!lines && args.query) {
855 const marker = block.kind === 'item' ? (/^\s*([-*+]|\d{1,3}[.)])\s/.exec(block.text)?.[1] ?? '-') + ' ' : ''
856 return (
857 <Text dimColor={args.dim} bold={block.kind === 'heading'}>
858 {marker}
859 {marked(t, `${block.index}`, plainText(block.text), args.query)}
860 </Text>
861 )
862 }
863 if (!lines) return <Markdown text={capMarkdown(block.text)} dimColor={args.dim} />
864
865 const language = block.kind === 'code' ? codeParts(block.text).language : undefined
866 const width = Math.max(0, ...lines.map(widthOf))
867 // Code drops an empty line, so a row scrolled past its end (or a blank line
868 // of code) keeps one space: the block holds its height at every pan.
869 const shown = lines.map(l => (args.pan > 0 ? [...l].slice(args.pan).join('') : l) || ' ')
870 const isWide = width > args.room
871 const range = panRange(block, args.room)
872 const query = args.query
873 return (
874 <Box flexDirection="column">
875 {query ? (
876 // The same lines, panned the same way, as text: Code cannot mark a span.
877 lines.map((line, k) => (
878 <Text key={`ln:${block.index}:${k}`} dimColor={args.dim} wrap="truncate-end">
879 {marked(t, `${block.index}:${k}`, line, query, args.pan)}
880 </Text>
881 ))
882 ) : (
883 <Code source={capCode(shown.join('\n'))} {...(language ? { language } : {})} wrap="truncate-end" />
884 )}
885 {isWide && drawScrollbar($, t, { at: block.index, pan: args.pan, width, room: args.room, ...range })}
886 </Box>
887 )
888}
889
890/**
891 * The scrollbar under a wide table or code block: `‹`, a track of pressable
892 * segments with the thumb drawn bold, `›`, and the columns in view. A press on
893 * a segment centres the thumb there; on a block not yet current it selects it.
894 * Buttons, not a Client: they work on every surface and never take the keys.
895 */
896function drawScrollbar(
897 $: EngineInterface,
898 t: Table,
899 a: { at: number; pan: number; width: number; room: number; max: number; step: number },
900): RenderElement {
901 const { Box, Text, Button } = t
902 // The label takes the room of its widest form, so the track's width never
903 // depends on where the block is panned to: the bar holds its size.
904 const labelRoom = `${a.width}–${a.width}/${a.width}`.length
905 const label = `${a.pan + 1}–${Math.min(a.width, a.pan + a.room)}/${a.width}`.padEnd(labelRoom)
906 // ‹, ›, the label and the gaps between them take the rest of the row.
907 const track = Math.max(8, a.room - labelRoom - 6)
908 // At most 30 segments (each one a Button), each as wide as fills the track.
909 const cell = Math.max(2, Math.ceil(track / 30))
910 const segments = Math.max(4, Math.floor(track / cell))
911 const thumb = Math.max(1, Math.min(segments, Math.round((segments * a.room) / a.width)))
912 const travel = segments - thumb
913 const start = a.max === 0 || travel === 0 ? 0 : Math.round((travel * a.pan) / a.max)
914 const panAt = (k: number) => (travel === 0 ? 0 : Math.round((Math.max(0, Math.min(travel, k - Math.floor(thumb / 2))) * a.max) / travel))
915
916 return (
917 <Box flexDirection="row" columnGap={1}>
918 <Button key={`pan-left:${a.at}`} plain dimColor={a.pan === 0} label="‹" onPress={() => void pan($, a.at, { by: -a.step }, a.max)} />
919 <Box flexDirection="row" flexShrink={0}>
920 {Array.from({ length: segments }, (_, k) => {
921 const isThumb = k >= start && k < start + thumb
922 return (
923 <Button
924 key={`seg:${a.at}:${k}`}
925 plain
926 dimColor={!isThumb}
927 label={(isThumb ? '━' : '─').repeat(cell)}
928 onPress={() => void pan($, a.at, { to: panAt(k) }, a.max)}
929 />
930 )
931 })}
932 </Box>
933 <Button key={`pan-right:${a.at}`} plain dimColor={a.pan >= a.max} label="›" onPress={() => void pan($, a.at, { by: a.step }, a.max)} />
934 <Box flexShrink={0}>
935 <Text dimColor wrap="truncate-end">
936 {label}
937 </Text>
938 </Box>
939 </Box>
940 )
941}
942
943function capHunk(hunk: string): string {
944 if (hunk.length <= CODE_CAP) return hunk
945 // Cut on a line so the hunk still parses; the header stays whole.
946 const cut = hunk.lastIndexOf('\n', CODE_CAP)
947 return hunk.slice(0, cut > 0 ? cut : CODE_CAP)
948}
949
950function drawDiff($: EngineInterface, t: Table, doc: DocReviewDoc, notice: string | null): RenderElement {
951 const { Box, Text, Button, Code } = t
952 const d = diffText(doc.baselineText, doc.text)
953
954 return (
955 <Box flexDirection="column">
956 <Box flexDirection="row" gap={1}>
957 <Text bold>{doc.title}</Text>
958 <Box flexShrink={1}>
959 <Text dimColor wrap="truncate-middle">
960 {doc.path}
961 </Text>
962 </Box>
963 </Box>
964 <Text dimColor>
965 diff against the version you reviewed
966 {d ? ` · +${d.added} −${d.removed} lines · ${doc.changed.length} ${doc.changed.length === 1 ? 'block' : 'blocks'} changed` : ''}
967 {doc.revision > 0 ? ` · revision ${doc.revision}` : ''}
968 </Text>
969 {notice && <Text color="green">{notice}</Text>}
970 <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginBottom={1}>
971 <Button key="diff" plain hotkey="d" label="document" onPress={() => void toggleView($)} />
972 <Button key="next-change" plain hotkey="r" label="next revised" onPress={() => void nextChange($)} />
973 <Button key="reviewed" plain hotkey="v" label="mark viewed" onPress={() => void markReviewed($)} />
974 <Button key="close" plain hotkey="q" label="close" onPress={() => void $.ui.close({ id: PANE })} />
975 </Box>
976 {!d && <Text dimColor>The two versions are too long to diff here.</Text>}
977 {d && d.hunks.length === 0 && <Text dimColor>No changes since the version you reviewed.</Text>}
978 {d &&
979 d.hunks.slice(0, MAX_HUNKS).map((hunk, i) => (
980 <Box key={`hunk:${i}`} flexDirection="column" marginBottom={1}>
981 <Code source={capHunk(hunk)} format="diff" wrap="wrap" />
982 </Box>
983 ))}
984 {d && d.hunks.length > MAX_HUNKS && <Text dimColor>… {d.hunks.length - MAX_HUNKS} more hunks not shown</Text>}
985 </Box>
986 )
987}
988
989function drawPane(
990 $: EngineInterface,
991 args: {
992 t: Table
993 Input: ElementConstructor<InputProps> | null
994 doc: DocReviewDoc | null
995 comments: readonly DocReviewComment[]
996 threads: readonly DocReviewThread[]
997 composer: DocReviewComposer
998 notice: string | null
999 /** Cells across the pane's body. */
1000 columns: number
1001 approvePhrase: string
1002 explainModel: string
1003 /** The configured default for side questions. */
1004 askModel: string
1005 /** Where the next side question goes: this session's pick, else askModel. */
1006 askVia: string
1007 /** The first row of the tree the pane's window shows. */
1008 offset: number
1009 },
1010): RenderElement {
1011 const { t, Input, doc, comments, threads, composer, notice } = args
1012 const { Box, Text, Button, Markdown } = t
1013
1014 if (!doc) {
1015 return (
1016 <Box flexDirection="column">
1017 <Text dimColor>No document open. Type /{COMMAND} <path> to review a markdown file.</Text>
1018 <Button key="close" plain hotkey="q" label="close" onPress={() => void $.ui.close({ id: PANE })} />
1019 </Box>
1020 )
1021 }
1022 if (doc.view === 'diff') return drawDiff($, t, doc, notice)
1023
1024 // With a text field the composer draws under the block; without one the
1025 // engine's dialog takes the text.
1026 const compose = (mode: 'comment' | 'ask', at?: number) => (Input ? openComposer($, mode, at) : composeViaDialog($, mode, args.askVia, at))
1027
1028 const n = doc.blocks.length
1029 const { lo, hi } = windowOf(n, doc.cursor)
1030 const live = comments.filter(c => !c.isOrphan)
1031 const orphans = comments.filter(c => c.isOrphan)
1032 const changed = new Set(doc.changed)
1033 const matched = new Set(doc.search?.matches ?? [])
1034 const isFinding = composer?.mode === 'find'
1035 const isNarrow = args.columns < NARROW
1036 const gutter = isNarrow ? 2 : 3
1037 const roomFor = (block: DocReviewBlock) => Math.max(10, args.columns - gutter - (block.depth ?? 0) * 2)
1038
1039 /**
1040 * A block's actions: always shown on the current block, shown on any other
1041 * while the pointer is on it. They sit in the blank row under the block, so
1042 * nothing moves. An item of a tight list has no blank row under it (the
1043 * next item starts there and would paint over them), so its actions sit at
1044 * the right end of its own last line instead, drawn over it.
1045 */
1046 const actionRow = (i: number, isCurrent: boolean, isTight: boolean) => {
1047 const at = doc.blocks[i]
1048 const place = isTight ? { bottom: 0, right: 0 } : { bottom: -1, left: gutter + (at?.depth ?? 0) * 2 }
1049 return (
1050 // Unkeyed: a key would make it a hover scope of its own, and a hidden
1051 // Box is never under the pointer.
1052 <Box
1053 position="absolute"
1054 {...place}
1055 flexDirection="row"
1056 {...(isCurrent ? {} : { display: 'none' as const, hover: { display: 'flex' as const } })}
1057 >
1058 {isTight && <Text>{' '}</Text>}
1059 <Button key={`act-c:${i}`} plain dimColor label="comment" onPress={() => void compose('comment', i)} />
1060 <Text dimColor> · </Text>
1061 <Button key={`act-a:${i}`} plain dimColor label="ask" onPress={() => void compose('ask', i)} />
1062 <Text dimColor> · </Text>
1063 <Button key={`act-h:${i}`} plain dimColor label="explain" onPress={() => void explain($, args.explainModel, i)} />
1064 </Box>
1065 )
1066 }
1067
1068 const rows: RenderElement[] = []
1069 for (let i = lo; i < hi; i += 1) {
1070 const block = doc.blocks[i]
1071 if (!block) continue
1072 const isCurrent = i === doc.cursor
1073 const isChanged = changed.has(i)
1074 const own = live.filter(c => c.anchor.blockIndex === i)
1075 const ownThreads = threads.filter(th => th.anchor.blockIndex === i)
1076 const indent = (block.depth ?? 0) * 2
1077 // Items of one list sit tight, as the list would.
1078 // The current one keeps its gap: its actions sit there.
1079 const isTight = block.kind === 'item' && doc.blocks[i + 1]?.kind === 'item' && !isCurrent
1080 const under = gutter + indent
1081
1082 rows.push(
1083 <Box key={`blk:${i}`} flexDirection="column" marginBottom={isTight ? 0 : 1}>
1084 <Box key={`pre:${i}`} position="absolute" top={-BAR_ROWS} left={0} width={1} height={1} />
1085 <Box flexDirection="row">
1086 <Box width={gutter}>
1087 <Button
1088 key={`b:${i}`}
1089 plain
1090 dimColor={!isCurrent && !isChanged}
1091 label={isCurrent ? '▶' : isChanged ? '+' : '·'}
1092 hover={{ dimColor: false, bold: true }}
1093 // A click selects the block; on the current one (Enter after Tab, a second click) it comments.
1094 onPress={() => void (isCurrent ? compose('comment', i) : setCursor($, i))}
1095 />
1096 </Box>
1097 <Box flexDirection="column" flexGrow={1} flexShrink={1} marginLeft={indent}>
1098 {drawBody($, t, block, {
1099 room: roomFor(block),
1100 pan: isCurrent ? (doc.pan ?? 0) : 0,
1101 dim: !isCurrent && composer !== null && composer.mode !== 'find',
1102 isCurrent,
1103 ...(doc.search && matched.has(i) ? { query: doc.search.query } : {}),
1104 })}
1105 </Box>
1106 </Box>
1107 {own.map(c => (
1108 <Box key={`cm:${c.id}`} flexDirection="row" marginLeft={under}>
1109 <Text color="yellow">✎ </Text>
1110 <Box flexGrow={1}>
1111 <Text color="yellow" wrap="wrap">
1112 {c.text}
1113 </Text>
1114 </Box>
1115 <Button key={`rm:${c.id}`} plain dimColor label="✕" onPress={() => void removeComment($, c.id)} />
1116 </Box>
1117 ))}
1118 {ownThreads.map(th => (
1119 <Box key={`th:${th.id}`} flexDirection="column" marginLeft={under}>
1120 <Text color="cyan" wrap="wrap">
1121 {th.kind === 'explain' ? 'ⓘ' : '?'} {th.question}
1122 {th.kind === 'explain' && th.model ? ` (${th.model})` : ''}
1123 {th.kind === 'ask' && th.model
1124 ? th.alone === 'chosen'
1125 ? ` (answered by ${th.model} from the document alone)`
1126 : ` (answered by ${th.model} from the document alone: the conversation had no reply yet)`
1127 : ''}
1128 </Text>
1129 {th.status === 'pending' && <Text dimColor>{th.kind === 'explain' ? 'explaining…' : 'asking…'}</Text>}
1130 {th.status === 'failed' && <Text color="red">could not ask: {th.failure ?? 'unknown'}</Text>}
1131 {th.status === 'answered' && <Markdown text={capMarkdown(th.answer ?? '')} dimColor />}
1132 {th.status !== 'pending' && (
1133 <Box flexDirection="row" gap={1}>
1134 {th.kind === 'ask' && <Button key={`keep:${th.id}`} plain label="keep as comment" onPress={() => void keepAsComment($, th)} />}
1135 {th.kind === 'ask' && <Button key={`send:${th.id}`} plain label="send to conversation" onPress={() => void escalate($, th)} />}
1136 <Button key={`drop:${th.id}`} plain dimColor label="dismiss" onPress={() => void dismissThread($, th.id)} />
1137 {th.status === 'answered' && th.outputTokens !== undefined && (
1138 <Text dimColor>
1139 {th.outputTokens} out · {th.cachedTokens ?? 0} cached
1140 </Text>
1141 )}
1142 </Box>
1143 )}
1144 </Box>
1145 ))}
1146 {composer && composer.mode !== 'find' && composer.blockIndex === i && Input && (
1147 <Box flexDirection="row" marginLeft={under} gap={1}>
1148 <Input
1149 key="compose"
1150 autoFocus
1151 label={composer.mode === 'ask' ? 'ask' : 'comment'}
1152 placeholder={composer.mode === 'ask' ? 'a question about this passage' : 'what should change here'}
1153 submitLabel={composer.mode === 'ask' ? 'ask' : 'add'}
1154 onSubmit={value => {
1155 const text = value.trim()
1156 if (text === '') return
1157 if (composer.mode === 'ask') void ask($, doc, block, text, args.askVia)
1158 else void addComment($, block, text)
1159 }}
1160 />
1161 {composer.mode === 'ask' && (
1162 <Button
1163 key="ask-via"
1164 plain
1165 dimColor
1166 label={viaLabel(args.askVia)}
1167 onPress={() => {
1168 const choices = askChoices(args.askModel)
1169 const next = choices[(choices.indexOf(args.askVia) + 1) % choices.length]!
1170 void update($, askViaA, () => next)
1171 }}
1172 />
1173 )}
1174 <Button key="cancel" plain dimColor label="cancel" onPress={() => void update($, composerA, () => null)} />
1175 </Box>
1176 )}
1177 {actionRow(i, isCurrent, isTight)}
1178 </Box>,
1179 )
1180 }
1181
1182 const current = doc.blocks[doc.cursor]
1183 const currentWidth = current ? unwrappedWidth(current) : 0
1184 const { max: panMax, step: panStep } = current ? panRange(current, roomFor(current)) : { max: 0, step: 8 }
1185
1186 return (
1187 <Box flexDirection="column">
1188 {/* Room for the bar at the top while the find field is open. */}
1189 {isFinding && Input && <Box key="find-room" height={BAR_ROWS} />}
1190 <Box flexDirection={isNarrow ? 'column' : 'row'} columnGap={1}>
1191 <Text bold>{doc.title}</Text>
1192 <Box flexShrink={1}>
1193 <Text dimColor wrap="truncate-middle">
1194 {doc.path}
1195 </Text>
1196 </Box>
1197 </Box>
1198 <Text dimColor>
1199 block {Math.min(doc.cursor + 1, n)}/{n} · {live.length} {live.length === 1 ? 'comment' : 'comments'}
1200 {orphans.length > 0 ? ` (${orphans.length} orphaned)` : ''}hooks/blocks.ts 330 lines1// Markdown to blocks, and anchors that survive a revision of the file.
2
3import type { DocReviewAnchor, DocReviewBlock, DocReviewBlockKind } from '../types'
4
5const FENCE = /^\s{0,3}(`{3,}|~{3,})/
6const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
7const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
8const LIST = /^\s*(?:[-*+]|\d{1,3}[.)])\s+/
9const QUOTE = /^\s{0,3}>/
10const TABLE_SEP = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
11
12/** Strips what a surface cannot draw: carriage returns and control characters. */
13export function cleanText(text: string): string {
14 return text.replace(/\r/g, '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
15}
16
17export function parseBlocks(text: string): DocReviewBlock[] {
18 const lines = cleanText(text).split('\n')
19 const blocks: DocReviewBlock[] = []
20 const stack: { level: number; title: string }[] = []
21 let i = 0
22
23 const push = (kind: DocReviewBlockKind, start: number, end: number, headingPath: string[], depth?: number) => {
24 const slice = lines.slice(start, end + 1)
25 // An item is drawn on its own, so its indent goes: four spaces would make it code.
26 const body = (depth === undefined ? slice : dedent(slice, indentOf(slice[0] ?? ''))).join('\n').trimEnd()
27 if (body.trim() === '') return
28 blocks.push({
29 index: blocks.length,
30 kind,
31 text: body,
32 startLine: start + 1,
33 endLine: end + 1,
34 headingPath,
35 ...(depth === undefined ? {} : { depth }),
36 })
37 }
38 const pathNow = () => stack.map(h => h.title)
39
40 while (i < lines.length) {
41 const line = lines[i] ?? ''
42 if (line.trim() === '') {
43 i += 1
44 continue
45 }
46
47 const fence = FENCE.exec(line)
48 if (fence) {
49 const mark = fence[1] ?? '```'
50 const start = i
51 i += 1
52 while (i < lines.length) {
53 const l = lines[i] ?? ''
54 if (l.trim().startsWith(mark[0] ?? '`') && l.trim().length >= mark.length && /^[`~]+\s*$/.test(l.trim())) break
55 i += 1
56 }
57 push('code', start, Math.min(i, lines.length - 1), pathNow())
58 i += 1
59 continue
60 }
61
62 const heading = HEADING.exec(line)
63 if (heading) {
64 const level = (heading[1] ?? '#').length
65 const title = (heading[2] ?? '').trim()
66 while (stack.length > 0 && (stack[stack.length - 1]?.level ?? 0) >= level) stack.pop()
67 stack.push({ level, title })
68 push('heading', i, i, pathNow())
69 i += 1
70 continue
71 }
72
73 if (RULE.test(line)) {
74 push('rule', i, i, pathNow())
75 i += 1
76 continue
77 }
78
79 if (line.includes('|') && TABLE_SEP.test(lines[i + 1] ?? '')) {
80 const start = i
81 i += 2
82 while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') i += 1
83 push('table', start, i - 1, pathNow())
84 continue
85 }
86
87 if (QUOTE.test(line)) {
88 const start = i
89 while (i < lines.length && QUOTE.test(lines[i] ?? '')) i += 1
90 push('quote', start, i - 1, pathNow())
91 continue
92 }
93
94 if (LIST.test(line)) {
95 // Each item is a block of its own, so a comment can land on one point; a
96 // nested item follows its parent one level deeper. `indents` holds the
97 // indents of the items open above the current one.
98 const indents: number[] = []
99 let start = -1
100 let depth = 0
101 let last = i
102 while (i < lines.length) {
103 const l = lines[i] ?? ''
104 if (l.trim() === '') {
105 // A blank line ends the list unless an item or an indented line follows.
106 let k = i + 1
107 while (k < lines.length && (lines[k] ?? '').trim() === '') k += 1
108 const after = lines[k] ?? ''
109 if (k < lines.length && (LIST.test(after) || /^\s{2,}\S/.test(after)) && !FENCE.test(after)) {
110 i = k
111 continue
112 }
113 break
114 }
115 if (HEADING.test(l) || FENCE.test(l)) break
116 if (LIST.test(l)) {
117 if (start !== -1) push('item', start, last, pathNow(), depth)
118 const w = indentOf(l)
119 while (indents.length > 0 && (indents[indents.length - 1] ?? 0) >= w) indents.pop()
120 depth = indents.length
121 indents.push(w)
122 start = i
123 }
124 last = i
125 i += 1
126 }
127 if (start !== -1) push('item', start, last, pathNow(), depth)
128 continue
129 }
130
131 const start = i
132 i += 1
133 while (
134 i < lines.length &&
135 (lines[i] ?? '').trim() !== '' &&
136 !HEADING.test(lines[i] ?? '') &&
137 !FENCE.test(lines[i] ?? '') &&
138 !LIST.test(lines[i] ?? '') &&
139 !QUOTE.test(lines[i] ?? '')
140 ) {
141 i += 1
142 }
143 push('paragraph', start, i - 1, pathNow())
144 }
145
146 return blocks
147}
148
149/** Leading whitespace in columns, a tab counting four. */
150function indentOf(line: string): number {
151 const lead = /^[ \t]*/.exec(line)?.[0] ?? ''
152 return lead.replace(/\t/g, ' ').length
153}
154
155/** Each line with up to `n` columns of its leading whitespace removed. */
156function dedent(lines: readonly string[], n: number): string[] {
157 return lines.map(l => {
158 const lead = /^[ \t]*/.exec(l)?.[0] ?? ''
159 const cut = Math.min(n, lead.replace(/\t/g, ' ').length)
160 return lead.replace(/\t/g, ' ').slice(cut) + l.slice(lead.length)
161 })
162}
163
164/** One table row's cells, an escaped pipe kept, emphasis and code marks dropped. */
165function splitRow(line: string): string[] {
166 let s = line.trim()
167 if (s.startsWith('|')) s = s.slice(1)
168 if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1)
169 const cells: string[] = []
170 let cell = ''
171 for (let i = 0; i < s.length; i += 1) {
172 const c = s[i] ?? ''
173 if (c === '\\' && s[i + 1] === '|') {
174 cell += '|'
175 i += 1
176 } else if (c === '|') {
177 cells.push(cell)
178 cell = ''
179 } else {
180 cell += c
181 }
182 }
183 cells.push(cell)
184 return cells.map(c => c.trim().replace(/\*\*|__|`/g, ''))
185}
186
187function widthOf(text: string): number {
188 return [...text].length
189}
190
191/**
192 * A markdown table laid out as aligned monospace rows: header, a rule, then
193 * the body, cells padded to their column and aligned as the separator says.
194 * Drawn unwrapped, a row never breaks across lines.
195 */
196export function tableGrid(markdown: string): string[] {
197 const rows = markdown.split('\n').filter(l => l.trim() !== '')
198 const header = splitRow(rows[0] ?? '')
199 const align = splitRow(rows[1] ?? '').map(c => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'))
200 const body = rows.slice(2).map(splitRow)
201 const all = [header, ...body]
202 const n = Math.max(...all.map(r => r.length))
203 const widths = Array.from({ length: n }, (_, j) => Math.max(1, ...all.map(r => widthOf(r[j] ?? ''))))
204 const pad = (text: string, j: number) => {
205 const room = (widths[j] ?? 0) - widthOf(text)
206 const how = align[j] ?? 'left'
207 if (how === 'right') return ' '.repeat(room) + text
208 if (how === 'center') return ' '.repeat(Math.floor(room / 2)) + text + ' '.repeat(Math.ceil(room / 2))
209 return text + ' '.repeat(room)
210 }
211 const line = (r: readonly string[]) => widths.map((_, j) => pad(r[j] ?? '', j)).join(' │ ').trimEnd()
212 return [line(header), widths.map(w => '─'.repeat(w)).join('─┼─'), ...body.map(line)]
213}
214
215/** A fenced code block's language (its info string's first word) and its lines, fences dropped. */
216export function codeParts(markdown: string): { language?: string; lines: string[] } {
217 const lines = markdown.split('\n')
218 const open = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/.exec(lines[0] ?? '')
219 const mark = open?.[1] ?? '```'
220 const inner = lines.slice(1)
221 const close = inner[inner.length - 1]?.trim() ?? ''
222 if (close.startsWith(mark) && /^[`~]+$/.test(close)) inner.pop()
223 const language = open?.[2] ?? ''
224 return language === '' ? { lines: inner } : { language, lines: inner }
225}
226
227/** The widest line a block draws unwrapped: a table's grid, a code block's lines. */
228export function unwrappedLines(block: Pick<DocReviewBlock, 'kind' | 'text'>): string[] | null {
229 if (block.kind === 'table') return tableGrid(block.text)
230 if (block.kind === 'code') return codeParts(block.text).lines
231 return null
232}
233
234/** The document's title: its first heading, else its file name. */
235export function titleOf(blocks: readonly DocReviewBlock[], path: string): string {
236 const first = blocks.find(b => b.kind === 'heading')
237 return first ? plainText(first.text) : basename(path)
238}
239
240export function basename(path: string): string {
241 const parts = path.split(/[\\/]/)
242 return parts[parts.length - 1] ?? path
243}
244
245/** Markdown markers removed and whitespace collapsed, for quoting and matching. */
246export function plainText(markdown: string): string {
247 return markdown
248 .replace(/^\s{0,3}#{1,6}\s+/gm, '')
249 .replace(/^\s*(?:[-*+]|\d{1,3}[.)])\s+/gm, '')
250 .replace(/^\s{0,3}>\s?/gm, '')
251 .replace(/^\s{0,3}(`{3,}|~{3,}).*$/gm, '')
252 .replace(/[*_`~]/g, '')
253 .replace(/\s+/g, ' ')
254 .trim()
255}
256
257export function normalizeQuote(markdown: string): string {
258 return plainText(markdown).toLowerCase().slice(0, 200)
259}
260
261export function anchorFor(block: DocReviewBlock): DocReviewAnchor {
262 return {
263 headingPath: [...block.headingPath],
264 quote: normalizeQuote(block.text),
265 blockIndex: block.index,
266 }
267}
268
269/**
270 * Finds the block an anchor means in the current blocks: an exact quote
271 * first, then a quote under the same heading sharing a long prefix, then the
272 * index hint when its block still shares a short prefix. -1 when none.
273 */
274export function reanchor(anchor: DocReviewAnchor, blocks: readonly DocReviewBlock[]): number {
275 const quotes = blocks.map(b => normalizeQuote(b.text))
276 const exact = quotes.findIndex(q => q === anchor.quote && q !== '')
277 if (exact !== -1) return exact
278
279 const prefix = anchor.quote.slice(0, 40)
280 if (prefix.length >= 12) {
281 const samePath = blocks.findIndex(
282 (b, i) => sameHeading(b.headingPath, anchor.headingPath) && (quotes[i] ?? '').startsWith(prefix),
283 )
284 if (samePath !== -1) return samePath
285 const anyPath = quotes.findIndex(q => q.startsWith(prefix))
286 if (anyPath !== -1) return anyPath
287 }
288
289 // A passage split in two keeps its comment on the first part: a list that
290 // was one block, now one block per item.
291 const split = blocks.findIndex(
292 (b, i) => sameHeading(b.headingPath, anchor.headingPath) && (quotes[i] ?? '').length >= 12 && anchor.quote.startsWith(quotes[i] ?? ''),
293 )
294 if (split !== -1) return split
295
296 const hinted = quotes[anchor.blockIndex]
297 if (hinted !== undefined && anchor.quote.length >= 8 && hinted.startsWith(anchor.quote.slice(0, 20))) {
298 return anchor.blockIndex
299 }
300 return -1
301}
302
303function sameHeading(a: readonly string[], b: readonly string[]): boolean {
304 return a.length === b.length && a.every((h, i) => h === b[i])
305}
306
307/** The first `max` characters of a block as plain text, with an ellipsis when cut. */
308export function excerpt(block: Pick<DocReviewBlock, 'text'>, max = 160): string {
309 const plain = plainText(block.text)
310 return plain.length > max ? `${plain.slice(0, max - 1).trimEnd()}…` : plain
311}
312
313const WHAT: Record<DocReviewBlockKind, string> = {
314 heading: 'the heading',
315 paragraph: 'the paragraph',
316 list: 'the list',
317 item: 'the list item',
318 code: 'the code block',
319 table: 'the table',
320 quote: 'the quote',
321 rule: 'the rule',
322}
323
324/** "Under 'A > B', the paragraph beginning '…'" for a prompt. */
325export function describeBlock(block: DocReviewBlock): string {
326 const where = block.headingPath.length > 0 ? `under "${block.headingPath.join(' > ')}"` : 'at the top of the document'
327 const what = WHAT[block.kind]
328 return `${where}, ${what} beginning "${excerpt(block, 100)}"`
329}
330hooks/diff.ts 175 lines1// A line diff (Myers) and the unified hunks a Code element draws from it,
2// plus which blocks of a document changed against an earlier version.
3
4import type { DocReviewBlock } from '../types'
5import { normalizeQuote } from './blocks'
6
7export type DiffOp = { kind: 'same' | 'add' | 'del'; text: string }
8
9/** Above this many lines together the diff is skipped rather than computed. */
10export const DIFF_LINE_CAP = 12000
11
12/**
13 * The shortest edit script from `a` to `b`, as Myers finds it; null when the
14 * inputs are too large to diff within the cap.
15 */
16export function diffLines(a: readonly string[], b: readonly string[]): DiffOp[] | null {
17 const n = a.length
18 const m = b.length
19 if (n + m > DIFF_LINE_CAP) return null
20 if (n === 0) return b.map(text => ({ kind: 'add', text }))
21 if (m === 0) return a.map(text => ({ kind: 'del', text }))
22
23 const max = n + m
24 const offset = max
25 const trace: Int32Array[] = []
26 let v = new Int32Array(2 * max + 2)
27 v[offset + 1] = 0
28
29 let found = false
30 for (let d = 0; d <= max && !found; d += 1) {
31 const snapshot = new Int32Array(v)
32 trace.push(snapshot)
33 for (let k = -d; k <= d; k += 2) {
34 let x: number
35 if (k === -d || (k !== d && (v[offset + k - 1] ?? 0) < (v[offset + k + 1] ?? 0))) {
36 x = v[offset + k + 1] ?? 0
37 } else {
38 x = (v[offset + k - 1] ?? 0) + 1
39 }
40 let y = x - k
41 while (x < n && y < m && a[x] === b[y]) {
42 x += 1
43 y += 1
44 }
45 v[offset + k] = x
46 if (x >= n && y >= m) {
47 found = true
48 break
49 }
50 }
51 }
52
53 // Walk the trace back from the end to recover the path.
54 const ops: DiffOp[] = []
55 let x = n
56 let y = m
57 for (let d = trace.length - 1; d >= 0; d -= 1) {
58 const vd = trace[d]!
59 const k = x - y
60 let prevK: number
61 if (k === -d || (k !== d && (vd[offset + k - 1] ?? 0) < (vd[offset + k + 1] ?? 0))) {
62 prevK = k + 1
63 } else {
64 prevK = k - 1
65 }
66 const prevX = vd[offset + prevK] ?? 0
67 const prevY = prevX - prevK
68 while (x > prevX && y > prevY) {
69 x -= 1
70 y -= 1
71 ops.push({ kind: 'same', text: a[x] ?? '' })
72 }
73 if (d > 0) {
74 if (x === prevX) {
75 y -= 1
76 ops.push({ kind: 'add', text: b[y] ?? '' })
77 } else {
78 x -= 1
79 ops.push({ kind: 'del', text: a[x] ?? '' })
80 }
81 }
82 }
83 ops.reverse()
84 return ops
85}
86
87/** Unified-diff hunks with `context` unchanged lines around each change. */
88export function unifiedHunks(ops: readonly DiffOp[], context = 3): string[] {
89 const changed = ops.map(op => op.kind !== 'same')
90 if (!changed.some(Boolean)) return []
91
92 // Group changes whose context windows touch.
93 const groups: { start: number; end: number }[] = []
94 let i = 0
95 while (i < ops.length) {
96 if (!changed[i]) {
97 i += 1
98 continue
99 }
100 let start = Math.max(0, i - context)
101 let end = i
102 let j = i
103 while (j < ops.length) {
104 if (changed[j]) {
105 end = j
106 j += 1
107 } else {
108 // Look ahead: another change within 2*context keeps the hunk going.
109 let k = j
110 while (k < ops.length && !changed[k] && k - j < context * 2) k += 1
111 if (k < ops.length && changed[k]) {
112 j = k
113 } else {
114 break
115 }
116 }
117 }
118 end = Math.min(ops.length - 1, end + context)
119 const last = groups[groups.length - 1]
120 if (last && start <= last.end + 1) {
121 last.end = end
122 } else {
123 groups.push({ start, end })
124 }
125 i = end + 1
126 }
127
128 // Line numbers: walk ops once, keeping the old and new line at each index.
129 const oldAt: number[] = []
130 const newAt: number[] = []
131 let oldLine = 1
132 let newLine = 1
133 for (const op of ops) {
134 oldAt.push(oldLine)
135 newAt.push(newLine)
136 if (op.kind !== 'add') oldLine += 1
137 if (op.kind !== 'del') newLine += 1
138 }
139
140 return groups.map(g => {
141 let oldCount = 0
142 let newCount = 0
143 const body: string[] = []
144 for (let k = g.start; k <= g.end; k += 1) {
145 const op = ops[k]!
146 if (op.kind !== 'add') oldCount += 1
147 if (op.kind !== 'del') newCount += 1
148 body.push(`${op.kind === 'add' ? '+' : op.kind === 'del' ? '-' : ' '}${op.text}`)
149 }
150 const oldStart = oldAt[g.start] ?? 1
151 const newStart = newAt[g.start] ?? 1
152 return [`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, ...body].join('\n')
153 })
154}
155
156export function diffText(before: string, after: string, context = 3): { hunks: string[]; added: number; removed: number } | null {
157 const ops = diffLines(before.split('\n'), after.split('\n'))
158 if (ops === null) return null
159 return {
160 hunks: unifiedHunks(ops, context),
161 added: ops.filter(o => o.kind === 'add').length,
162 removed: ops.filter(o => o.kind === 'del').length,
163 }
164}
165
166/**
167 * The indices of blocks whose text is not found, as a block, in the earlier
168 * version: new or reworded passages. Unchanged blocks moved around are not
169 * reported, since they read the same.
170 */
171export function changedBlocks(current: readonly DocReviewBlock[], baselineBlocks: readonly DocReviewBlock[]): number[] {
172 const seen = new Set(baselineBlocks.map(b => normalizeQuote(b.text) + '\u0000' + b.text.trim()))
173 return current.filter(b => !seen.has(normalizeQuote(b.text) + '\u0000' + b.text.trim())).map(b => b.index)
174}
175hooks/persist.ts 54 lines1// The shape of what the mod keeps across sessions in `$.store`: one record
2// per document. The store calls themselves live in the hooks module, since
3// the engine follows `$` only into functions of that file.
4//
5// The store holds 4 MiB of JSON in all, so a record keeps a bounded baseline
6// text, and the records of the least recently touched documents are evicted.
7
8import type { DocReviewComment, DocReviewSaved, DocReviewThread } from '../types'
9
10export const STORE_PREFIX = 'doc:'
11export const MAX_SAVED_DOCS = 12
12const BASELINE_CAP = 120_000
13
14export function storeKey(cwd: string, path: string): string {
15 const p = path.replace(/\\/g, '/')
16 const base = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
17 const abs = p.startsWith('/') || /^[A-Za-z]:\//.test(p) ? p : `${base}/${p.replace(/^\.\//, '')}`
18 return `${STORE_PREFIX}${abs}`
19}
20
21export function isSaved(value: unknown): value is DocReviewSaved {
22 if (typeof value !== 'object' || value === null) return false
23 const v = value as Record<string, unknown>
24 return typeof v.path === 'string' && Array.isArray(v.comments) && Array.isArray(v.threads) && typeof v.updatedAt === 'number'
25}
26
27export function toSaved(
28 record: {
29 path: string
30 comments: readonly DocReviewComment[]
31 threads: readonly DocReviewThread[]
32 baselineText: string | null
33 lastReviewAt: number | null
34 },
35 now: number,
36): DocReviewSaved {
37 return {
38 path: record.path,
39 comments: [...record.comments],
40 // A pending question is a request in flight in one session; it does not carry over.
41 threads: record.threads.filter(t => t.status !== 'pending'),
42 baselineText: record.baselineText !== null && record.baselineText.length <= BASELINE_CAP ? record.baselineText : null,
43 lastReviewAt: record.lastReviewAt,
44 updatedAt: now,
45 }
46}
47
48/** Which of `others` to drop so that, with the one just written, at most MAX_SAVED_DOCS remain. */
49export function keysToEvict(others: readonly { key: string; updatedAt: number }[]): string[] {
50 if (others.length < MAX_SAVED_DOCS) return []
51 const aged = [...others].sort((a, b) => a.updatedAt - b.updatedAt)
52 return aged.slice(0, aged.length - (MAX_SAVED_DOCS - 1)).map(o => o.key)
53}
54hooks/review-prompt.ts 124 lines1// The texts the mod sends to the model: a review, a side question, an approval.
2
3import type { DocReviewBlock, DocReviewComment } from '../types'
4import { describeBlock, excerpt, reanchor } from './blocks'
5
6function quoteOf(comment: DocReviewComment, blocks: readonly DocReviewBlock[]): string {
7 const at = reanchor(comment.anchor, blocks)
8 const block = at === -1 ? undefined : blocks[at]
9 if (block) return `${describeBlock(block)}:\n > ${excerpt(block, 240)}`
10 const where = comment.anchor.headingPath.length > 0 ? `under "${comment.anchor.headingPath.join(' > ')}"` : 'in the document'
11 return `${where}, a passage that no longer appears, which began "${comment.anchor.quote.slice(0, 100)}"`
12}
13
14export function buildReviewPrompt(args: {
15 path: string
16 comments: readonly DocReviewComment[]
17 blocks: readonly DocReviewBlock[]
18}): string {
19 const n = args.comments.length
20 const lines = [
21 `I reviewed \`${args.path}\` and have ${n} ${n === 1 ? 'comment' : 'comments'}. Please revise the document to address each one, keep the rest as it is, and then summarise what changed per comment.`,
22 '',
23 ]
24 args.comments.forEach((c, i) => {
25 lines.push(`${i + 1}. ${quoteOf(c, args.blocks)}`)
26 lines.push(` Comment: ${c.text}`)
27 lines.push('')
28 })
29 return lines.join('\n').trimEnd()
30}
31
32export function buildAskPrompt(args: { path: string; block: DocReviewBlock; question: string }): string {
33 return [
34 `I am reviewing \`${args.path}\` and have a question about one passage, ${describeBlock(args.block)}:`,
35 '',
36 ...args.block.text.split('\n').map(l => `> ${l}`),
37 '',
38 `Question: ${args.question}`,
39 '',
40 'Answer from what you know of this document and the conversation. Do not edit any file; this is a side question during review.',
41 ].join('\n')
42}
43
44export function buildEscalationPrompt(args: {
45 path: string
46 block: DocReviewBlock
47 question: string
48 answer?: string
49}): string {
50 const lines = [
51 `About \`${args.path}\`, ${describeBlock(args.block)}:`,
52 '',
53 `> ${excerpt(args.block, 300)}`,
54 '',
55 args.question,
56 ]
57 if (args.answer) {
58 lines.push('', `(When I asked this on the side, you answered: "${args.answer.slice(0, 400)}")`)
59 }
60 return lines.join('\n')
61}
62
63export function buildApprovalPrompt(args: {
64 path: string
65 phrase: string
66 notes: readonly DocReviewComment[]
67 blocks: readonly DocReviewBlock[]
68}): string {
69 if (args.notes.length === 0) return args.phrase
70 const lines = [args.phrase, '', `A few non-blocking notes on \`${args.path}\` you may fold in as you go:`, '']
71 args.notes.forEach((c, i) => {
72 lines.push(`${i + 1}. ${quoteOf(c, args.blocks)}`)
73 lines.push(` Note: ${c.text}`)
74 lines.push('')
75 })
76 return lines.join('\n').trimEnd()
77}
78
79/** A fresh small model's brief: the passage and the document's title, nothing more. */
80export function buildExplainPrompt(args: { path: string; title: string; block: DocReviewBlock }): { system: string; prompt: string } {
81 return {
82 system:
83 'You explain passages of software design documents to their reviewer. Answer in plain words, in at most four short sentences. Define any jargon or acronym the passage uses. Do not evaluate or suggest changes; only explain what it says and means.',
84 prompt: [
85 `Document: "${args.title}" (${args.path}).`,
86 `Passage, ${describeBlock(args.block)}:`,
87 '',
88 ...args.block.text.split('\n').map(l => `> ${l}`),
89 '',
90 'Explain this passage.',
91 ].join('\n'),
92 }
93}
94
95const STANDALONE_DOC_CAP = 100_000
96
97/**
98 * A question asked before the conversation has a reply to fork from: the
99 * session's model sees the whole document instead.
100 */
101export function buildStandaloneAskPrompt(args: { path: string; title: string; text: string; block: DocReviewBlock; question: string }): {
102 system: string
103 prompt: string
104} {
105 const text = args.text.length > STANDALONE_DOC_CAP ? `${args.text.slice(0, STANDALONE_DOC_CAP)}\n\n[document truncated for length]` : args.text
106 return {
107 system:
108 'You are helping a reviewer understand a software design document they are reading. Answer their question from the document given, concisely. Say so when the document does not settle the question. Do not propose edits unless asked; this is a side question during review.',
109 prompt: [
110 `The document "${args.title}" (\`${args.path}\`):`,
111 '',
112 '<document>',
113 text,
114 '</document>',
115 '',
116 `The reviewer's question is about one passage, ${describeBlock(args.block)}:`,
117 '',
118 ...args.block.text.split('\n').map(l => `> ${l}`),
119 '',
120 `Question: ${args.question}`,
121 ].join('\n'),
122 }
123}
124types/index.d.ts 129 lines1// The doc-review mod's state contract: every value it keeps in `$.state`.
2
3export type DocReviewBlockKind =
4 | 'heading'
5 | 'paragraph'
6 /** A whole list: what an earlier version made; lists now split into items. */
7 | 'list'
8 /** One list item, its nested items excluded (each is a block of its own). */
9 | 'item'
10 | 'code'
11 | 'table'
12 | 'quote'
13 | 'rule'
14
15export type DocReviewBlock = {
16 index: number
17 kind: DocReviewBlockKind
18 /** The block's raw markdown. */
19 text: string
20 /** 1-based line range in the file. */
21 startLine: number
22 endLine: number
23 /** Headings above this block, outermost first; a heading block includes itself. */
24 headingPath: string[]
25 /** For an item: how deeply it nests, 0 at the list's top level. */
26 depth?: number
27}
28
29/** Where a comment belongs, independent of line numbers. */
30export type DocReviewAnchor = {
31 headingPath: string[]
32 /** The block's normalised opening text, at most 200 characters. */
33 quote: string
34 /** The block's index when the anchor was made; a hint only. */
35 blockIndex: number
36}
37
38export type DocReviewComment = {
39 id: string
40 anchor: DocReviewAnchor
41 text: string
42 /** True when the anchor no longer matches any block of the current text. */
43 isOrphan: boolean
44}
45
46export type DocReviewThread = {
47 id: string
48 anchor: DocReviewAnchor
49 /** `ask` goes to the conversation's model over its transcript; `explain` to a small fresh model. */
50 kind: 'ask' | 'explain'
51 question: string
52 status: 'pending' | 'answered' | 'failed'
53 /** The model that answered: set for an `explain` thread, and for an `ask` answered without the conversation. */
54 model?: string
55 /** Why an `ask` was answered from the document alone: the conversation had no reply yet, or the person picked another model. */
56 alone?: 'no-reply' | 'chosen'
57 answer?: string
58 failure?: string
59 outputTokens?: number
60 cachedTokens?: number
61}
62
63export type DocReviewDoc = {
64 path: string
65 title: string
66 /** The file's text as last read. */
67 text: string
68 blocks: DocReviewBlock[]
69 /** The focused block's index. */
70 cursor: number
71 /** How many times the file was re-read since opening. */
72 revision: number
73 /** The text the reviewer last read in full: set on open, on submit, on "mark reviewed". */
74 baselineText: string
75 /** Indices of blocks that differ from the baseline. */
76 changed: number[]
77 /** What the pane shows: the document, or the diff against the baseline. */
78 view: 'document' | 'diff'
79 /** True from a submitted review until the file next changes. */
80 awaitingRevision: boolean
81 /** When a review or approval was last sent for this document. */
82 lastReviewAt: number | null
83 /** The last find: its text and the blocks it matches, in order. */
84 search: { query: string; matches: number[] } | null
85 /** Columns the focused table or code block is scrolled right by; 0 when the cursor moves. */
86 pan?: number
87}
88
89/** One document's record in `$.store`, kept across sessions. */
90export type DocReviewSaved = {
91 path: string
92 comments: DocReviewComment[]
93 threads: DocReviewThread[]
94 baselineText: string | null
95 lastReviewAt: number | null
96 updatedAt: number
97}
98
99export type DocReviewComposer = {
100 /** The block it was opened on; for find, where the cursor goes back to on cancel. */
101 blockIndex: number
102 mode: 'comment' | 'ask' | 'find'
103 /** The find field's text when it opened: drawn once, so typing is never overwritten. */
104 initial?: string
105} | null
106
107export type DocReviewCandidate = {
108 path: string
109 writtenAt: number
110 /** The session's turn count when it was written. */
111 turn: number
112}
113
114declare module 'claude-code' {
115 interface PluginState {
116 'doc-review': {
117 doc: DocReviewDoc | null
118 comments: DocReviewComment[]
119 threads: DocReviewThread[]
120 composer: DocReviewComposer
121 candidates: DocReviewCandidate[]
122 offered: string[]
123 notice: string | null
124 /** The model side questions go to this session, picked in the ask composer: 'session' forks the conversation. Null until picked. */
125 askVia: string | null
126 }
127 }
128}
129