SLOPSHOPPER

doc-review

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

newpaneguardcommandtoaststatus
v0.4.0Apache-2.0updated 2026-10-06revelationnow/claude-doc-review/doc-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doc-review
│ ┃ doc-review ✕ › fix the failing auth test and add an audit log call │ ┃ No document open. Type /doc-review <path> to │ ┃ review a markdown file. ⏺ Read(src/auth.ts) │ ┃ q: close ⎿ Read 6 lines │ ⏺ 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 │ │ › /doc-review │ ⎿ doc-review: nothing to review yet. Usage: /doc-review <path-to-m │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · doc-review
No document open. Type /doc-review <path> to review a markdown file. q: close
README

claude-doc-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.

claude-doc-review in action: opening DESIGN.md, panning a wide table, commenting on a bullet, asking a side question, explaining a passage on haiku, and submitting the 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>

Why

  • One review, one revision. All comments go back as a single prompt, each one anchored by heading path and quote. Claude revises the document once and keeps it coherent, instead of patching it piece by piece.
  • Side questions stay on the side. "What does this paragraph mean?" doesn't belong in the main transcript. Asks are answered by a fork of the session, which reuses the cached prefix, so they're cheap and leave no trace in the conversation.
  • Comments land exactly where you mean. Every heading, paragraph, list item, table and code fence is its own stop, and comments follow their passage when Claude edits the file.

Install

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

Quick start

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.

What it does

Spots specs and plansA 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 linesEach heading, paragraph, list item (nested ones too), code fence, table and quote is a focus stop.
Tables and code never wrapTables 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 commentsA 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.
EscalateUnder each answer: keep as comment, send to conversation (with the passage attached as hidden context), or dismiss.
Submit or approves 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 diffWhen 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.
PersistenceComments, 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.
Findf 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 barOnce you scroll down, a bar with your position and the main actions stays pinned to the top of the pane.
Cycle and jumpm cycles through blocks that have comments. g and e jump to the top and end.

Keys

Hotkeys work while the pane has the keyboard. It opens focused from /doc-review; otherwise click it or press ctrl+x tab.

KeyAction
j / kNext / previous block
g / eTop / end
h / lPan a wide table or code block left / right
fFind
n / pNext / previous match
mNext block with a comment or question
Tab / Shift+TabWalk the blocks and their actions
c, or Enter on the current markerComment
aAsk a side question
iExplain on a small model
sSubmit all comments as one review
yApprove (closes the pane)
r / d / vNext revised block / toggle diff / mark revision viewed (shown only after a revision)
q / EscClose the pane (comments are kept)

Mouse

The mouse works wherever the surface reports it: fullscreen terminal, the desktop app and VS Code.

  • Click a block's marker (·) to select it. Click the current marker (▶) to comment.
  • Hover a block to show 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.
  • Every key has a button, including the toolbar, the answer actions, the ✕ on a comment, and the approve dialog.
  • The wheel scrolls the pane. Documents with more than 200 blocks are drawn as a window around the current block, and the wheel moves the window at its edges.
  • Wide tables and code have a bar under them: ‹ ───━━━━━─── › 41–88/102. Click the track to jump there, or click ‹ and › to step a screen at a time.

Configuration

Set these in /config or under pluginConfigs.doc-review in settings.

OptionDefaultMeaning
globssuperpowers specs and plans, docs/plans, docs/specs, *-design.md, *-plan.md, SPEC.md, PLAN.mdComma-separated globs of documents that count
offerautoauto opens the pane when a review is requested. toast only shows a toast and status line. off means /doc-review only.
approvePhraseLooks good, proceed.What y sends as your words
askModelsessionWhere side questions go by default. session forks this conversation. Any model alias or id answers from the document alone.
explainModelhaikuThe 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.

Where it runs

SurfaceSupport
Terminal, fullscreenDocked pane, keys and mouse
Terminal, main screenInline pane above the prompt, keys only
Desktop app (Code tab)Docked pane, mouse, and the app's own text field and Markdown
VS CodeSame as the desktop app
MobileComment 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.

Developing

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.
  • An atom's reference must be written as string literals at the call site.

Deliberately not built

  • A vim-style 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.
  • A draggable scrollbar. It was built as a 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.
  • Tests for hover. The test kit drops hover styling, so the hover reveal is type-checked and validated but not tested. The tests do press the hidden actions and check that each block has exactly one action row.

License

Apache License 2.0.

Source 6 files
hooks/register.tsx 1544 lines
1// 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} &lt;path&gt; 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 lines
1// 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}
330
hooks/diff.ts 175 lines
1// 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}
175
hooks/persist.ts 54 lines
1// 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}
54
hooks/review-prompt.ts 124 lines
1// 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}
124
types/index.d.ts 129 lines
1// 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