Side pane showing the files changed in this conversation as diffs; mark lines, comment, and send the comments back to Claude

Mods for Claude Code by Rudra Secure.
| Mod | What it does |
|---|---|
review-pane | A side pane that shows every file Claude changed in the conversation as a diff. Mark lines, comment on them, and send the comments back to Claude, which answers each one. |
Review Claude's changes the way you review a pull request, without leaving the session.
A added, M modified, D deleted), +added −removed counts and a change bar. Edits made before a restart or a resume are rebuilt from the conversation."tui": "fullscreen" in ~/.claude/settings.json, or pick it in /config. Elsewhere the pane still works with the keyboard and clickable line numbers./changes opens it at any width.From the marketplace (recommended)
In Claude Code:
/plugin marketplace add rudrasecure/claude-mods
/plugin install review-pane@claude-mods
or from a shell:
claude plugin marketplace add rudrasecure/claude-mods
claude plugin install review-pane@claude-mods
Then restart Claude Code. To update later:
claude plugin marketplace update claude-mods
claude plugin update review-pane@claude-mods
and restart again.
From a clone
git clone https://github.com/rudrasecure/claude-mods.git
claude --plugin-dir ./claude-mods/review-pane
To load it in every session without the flag, add the folder to ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/claude-mods/review-pane" }
}
Run /changes to open the pane. It also opens by itself after Claude's first edit when the terminal is wide enough.
Mouse (fullscreen terminal)
| Do | To |
|---|---|
| Click a file in the list | Show its diff |
| Click a line | Move the cursor there |
| Drag, or shift-click | Select a range of lines |
| Click ✎ Comment at the end of a hovered line or a selection | Comment on it |
| Click a function, variable or type name | See its definition |
| Right-click a line, a selection or a name | Comment, show definition, go to definition, find references |
| Click ➤ Send | Send all comments to Claude |
Keyboard (once the pane has focus: click it, or open it with /changes)
| Key | Action | Key | Action |
|---|---|---|---|
j / k | Next / previous line | n / p | Next / previous file |
v | Start or clear a range | c | Comment on the line or range |
s | Send all comments | x | Discard all comments |
w | Next name on the line | h | Definition of that name |
g | Go to its definition | f | Find its references |
t | Highlighting on or off | r | Re-read files from disk |
Comments are drafts until you send them. Sent comments read like this to Claude:
I reviewed your changes and left a comment on specific lines:
1. `src/app.ts` L12-15
+const retries = 42
Use a named constant instead of 42.
Please address each comment, then briefly say what you changed for each.
Without a language server the pane finds definitions and references by searching the text of the changed files, and says so on the card. For real answers, install a language server and its Claude Code plugin from the official marketplace:
| Language | Server (install it yourself) | Claude Code plugin |
|---|---|---|
| TypeScript / JavaScript | npm install -g typescript-language-server typescript@5 | typescript-lsp@claude-plugins-official |
| Python | npm install -g pyright | pyright-lsp@claude-plugins-official |
| Go | go install golang.org/x/tools/gopls@latest (put $(go env GOPATH)/bin on your PATH) | gopls-lsp@claude-plugins-official |
| C / C++ | clangd from your package manager | clangd-lsp@claude-plugins-official |
| Rust | rustup component add rust-analyzer | rust-analyzer-lsp@claude-plugins-official |
| Lua | lua-language-server from your package manager | lua-lsp@claude-plugins-official |
claude plugin install typescript-lsp@claude-plugins-official # and so on, per language
Then restart Claude Code fully. Things we ran into:
npm install -g typescript now installs TypeScript 7, the native rewrite, which typescript-language-server cannot drive./exit followed by claude --continue reattaches to the same process. Run claude daemon stop --any in between.LSP tool. On some builds the tool Claude Code exposes to plugins is only listed when ENABLE_LSP_TOOL=1 is set. If cards keep saying language server: not loaded, add it to ~/.claude/settings.json and restart: { "env": { "ENABLE_LSP_TOOL": "1" } }
r to re-read listed files from disk.Each mod is a plugin folder: .claude-plugin/plugin.json, hooks/hooks.json naming the hooks module, the module itself, and types/index.d.ts declaring the state it keeps.
review-pane/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json # names register.tsx
│ ├── register.tsx # the hooks: tracking edits, the pane, comments, sending, LSP
│ ├── diff-view.tsx # mouse region drawing diff rows (click, drag, hover, right-click)
│ ├── file-list.tsx # mouse region drawing the file list
│ ├── diff.ts # line diff (Myers) into unified hunks
│ ├── symbols.ts # names on a line; the text-search fallback
│ └── review.test.tsx # tests, run with `claude plugin test`
├── types/index.d.ts # the mod's $.state contract
└── tsconfig.json
claude plugin validate review-pane # what the engine will load, and anything it would refuse
claude plugin test review-pane # the tests
claude --plugin-dir ./review-pane # try it; edits reload when the folder goes quiet
tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes (with the API's types) each time it loads the mod, so tsc -p review-pane type-checks it after one load. That folder is generated and not committed.
MIT © Rudra Secure
hooks/register.tsx 1020 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type {
5 ChangedFile,
6 Composer,
7 ContextMenu,
8 LspOperation,
9 LspView,
10 ReviewComment,
11 ReviewCursor,
12 SymbolRef,
13} from '../types'
14import type { ViewMessage, ViewProps, ViewRow } from './diff-view'
15import type { FileEntry, FileListMessage, FileListProps } from './file-list'
16import { countChanges, describeLines, diffRows, rowPrefix, type DiffRow } from './diff'
17import { expandTabs, fenceOf, findDeclaration, findUses, isKeyword, wordAt, wordsOf, type SourceFile } from './symbols'
18
19const PANE = 'review-pane'
20const TITLE = 'Changes'
21const FILE_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
22const MAX_ROWS = 2000
23const MAX_SNIPPET_LINES = 20
24const MAX_CODE_CHARS = 2000
25// Diff rows per mouse region at most: a long diff is several, so the cursor can
26// be scrolled to its block; a drag still spans them.
27const BLOCK_ROWS = 25
28const MAX_VIEW_CHARS = 60_000
29// Part of each mouse region's key: a region keeps its instance (and the code
30// it was mounted with) while its key stands, so a change to diff-view.tsx
31// bumps this to remount them.
32const VIEW_VERSION = 5
33const LSP_LABELS: Record<LspOperation, string> = {
34 hover: 'Definition',
35 goToDefinition: 'Go to definition',
36 findReferences: 'References',
37}
38
39const files = atom({ plugin: 'review-pane', key: 'files' } as const, [] as ChangedFile[])
40const comments = atom({ plugin: 'review-pane', key: 'comments' } as const, [] as ReviewComment[])
41const cursor = atom({ plugin: 'review-pane', key: 'cursor' } as const, {
42 file: 0,
43 row: 0,
44 anchor: null,
45} as ReviewCursor)
46const cwd = atom({ plugin: 'review-pane', key: 'cwd' } as const, '')
47const isTurnRunning = atom({ plugin: 'review-pane', key: 'isTurnRunning' } as const, false)
48const isSteerPending = atom({ plugin: 'review-pane', key: 'isSteerPending' } as const, false)
49const isHighlighted = atom({ plugin: 'review-pane', key: 'isHighlighted' } as const, true)
50const word = atom({ plugin: 'review-pane', key: 'word' } as const, 0)
51const lsp = atom({ plugin: 'review-pane', key: 'lsp' } as const, null as LspView | null)
52const menu = atom({ plugin: 'review-pane', key: 'menu' } as const, null as ContextMenu | null)
53const composer = atom({ plugin: 'review-pane', key: 'composer' } as const, null as Composer | null)
54const editing = atom({ plugin: 'review-pane', key: 'editing' } as const, null as string | null)
55
56// Diffs are recomputed only when a file changes; a reload just recomputes.
57const diffCache = new Map<string, DiffRow[]>()
58function rowsOf(file: ChangedFile): DiffRow[] {
59 const id = `${file.path}\0${file.updatedAt}`
60 let rows = diffCache.get(id)
61 if (!rows) {
62 rows = diffRows(file.before, file.after)
63 diffCache.set(id, rows)
64 }
65 return rows
66}
67
68function relative(path: string, root: string): string {
69 return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
70}
71
72async function readOrNull($: EngineInterface, path: string): Promise<string | null> {
73 try {
74 return await $.fs.read(path)
75 } catch {
76 return null
77 }
78}
79
80function selectedRange(c: ReviewCursor): [number, number] {
81 const anchor = c.anchor ?? c.row
82 return [Math.min(anchor, c.row), Math.max(anchor, c.row)]
83}
84
85// Hunk headers are not lines: the cursor steps over them in the direction it moves.
86function landRow(rows: DiffRow[], row: number, by: number): number {
87 const count = Math.min(rows.length, MAX_ROWS)
88 let at = Math.max(0, Math.min(row, count - 1))
89 while (rows[at]?.kind === 'hunk' && at + by >= 0 && at + by < count) at += by
90 if (rows[at]?.kind === 'hunk' && at + 1 < count) at++
91 return at
92}
93
94function firstChange(file: ChangedFile | undefined): number {
95 if (!file) return 0
96 const rows = rowsOf(file)
97 const at = rows.findIndex(r => r.kind === 'add' || r.kind === 'del')
98 return landRow(rows, at < 0 ? 0 : at, 1)
99}
100
101function clampCursor(c: ReviewCursor, list: ChangedFile[]): ReviewCursor {
102 const file = Math.max(0, Math.min(c.file, list.length - 1))
103 const all = list[file] ? rowsOf(list[file]!) : []
104 const rows = Math.min(all.length, MAX_ROWS)
105 const row = landRow(all, c.row, 1)
106 const anchor = c.anchor === null ? null : Math.max(0, Math.min(c.anchor, rows - 1))
107 return { file, row, anchor }
108}
109
110// Code accepts tab and newline as its only control characters.
111function codeSource(text: string): string {
112 const clean = text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
113 return (clean.length > MAX_CODE_CHARS ? clean.slice(0, MAX_CODE_CHARS) : clean) || ' '
114}
115
116async function current($: EngineInterface) {
117 const list = await read($, files)
118 const c = clampCursor(await read($, cursor), list)
119 const file = list[c.file]
120 return { list, c, file, rows: file ? rowsOf(file) : [] }
121}
122
123async function closePopups($: EngineInterface): Promise<void> {
124 if (await read($, menu)) await update($, menu, () => null)
125 if (await read($, lsp)) await update($, lsp, () => null)
126}
127
128// ── Comments ──────────────────────────────────────────────────────────────
129
130function formatReview(list: ReviewComment[], root: string): string {
131 const parts = list.map((c, i) => {
132 const where = `\`${relative(c.path, root)}\` ${c.lines}`
133 return `${i + 1}. ${where}\n\`\`\`diff\n${c.snippet}\n\`\`\`\n${c.text}`
134 })
135 return [
136 `I reviewed your changes and left ${list.length === 1 ? 'a comment' : `${list.length} comments`} on specific lines:`,
137 '',
138 parts.join('\n\n'),
139 '',
140 'Please address each comment, then briefly say what you changed for each.',
141 ].join('\n')
142}
143
144// Mid-turn the comments are appended to the running conversation (the model
145// reads them at its next step); otherwise they start a turn of their own.
146async function deliver($: EngineInterface, text: string): Promise<'steered' | 'sent'> {
147 if (await read($, isTurnRunning)) {
148 try {
149 const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
150 if (appended.deny === undefined) {
151 await update($, isSteerPending, () => true)
152 return 'steered'
153 }
154 } catch {
155 // Fall through to a prompt of its own once the turn ends.
156 }
157 }
158 await $.prompt.submit({ text, asUser: true })
159 return 'sent'
160}
161
162async function sendComments($: EngineInterface): Promise<void> {
163 const drafts = await read($, comments)
164 if (drafts.length === 0) {
165 $.ui.toast('review-pane: no comments to send')
166 return
167 }
168 const how = await deliver($, formatReview(drafts, await read($, cwd)))
169 await update($, comments, () => [])
170 await update($, editing, () => null)
171 $.ui.toast(
172 how === 'steered'
173 ? `Steered ${drafts.length} comment(s) into the running turn`
174 : `Sent ${drafts.length} comment(s) to Claude`,
175 )
176}
177
178// What the comment box holds as the person types: drawn back as its value,
179// so a redraw (a hover, a drag) keeps the text.
180let composerText = ''
181
182async function openComposer($: EngineInterface, lo: number, hi: number): Promise<void> {
183 composerText = ''
184 editText = ''
185 await update($, editing, () => null)
186 await update($, menu, () => null)
187 await update($, lsp, () => null)
188 await update($, cursor, cur => ({ ...cur, row: hi, anchor: lo === hi ? null : lo }))
189 await update($, composer, () => ({ lo, hi }))
190 revealRow($, hi)
191 void $.ui.focus({ requestId: PANE, key: 'comment' }).catch(() => {})
192}
193
194async function commentOnSelection($: EngineInterface): Promise<void> {
195 const { c } = await current($)
196 const [lo, hi] = selectedRange(c)
197 await openComposer($, lo, hi)
198}
199
200async function addComment($: EngineInterface, text: string): Promise<void> {
201 const body = text.trim()
202 const box = await read($, composer)
203 const { c, file, rows } = await current($)
204 if (!body || !file) return
205 const [lo, hi] = box ? [box.lo, box.hi] : selectedRange(c)
206 const picked = rows.slice(lo, hi + 1)
207 const lines = picked.filter(r => r.kind !== 'hunk')
208 const shown = lines.slice(0, MAX_SNIPPET_LINES).map(r => `${rowPrefix(r)}${r.text}`)
209 if (lines.length > MAX_SNIPPET_LINES) shown.push(`… ${lines.length - MAX_SNIPPET_LINES} more lines`)
210 const comment: ReviewComment = {
211 id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
212 path: file.path,
213 lines: describeLines(picked),
214 snippet: shown.join('\n'),
215 text: body,
216 }
217 await update($, comments, all => [...all, comment])
218 await update($, cursor, cur => ({ ...cur, anchor: null }))
219 await closeComposer($, hi)
220}
221
222// The row a comment's lines end on, where it is drawn: "L2-4" on the new
223// side, "old L3" on a removed line.
224function commentRow(comment: ReviewComment, rows: DiffRow[]): number | undefined {
225 const m = /^(old )?L(\d+)(?:-(\d+))?$/.exec(comment.lines)
226 if (!m) return undefined
227 const end = Number(m[3] ?? m[2])
228 const isOld = Boolean(m[1])
229 for (let i = rows.length - 1; i >= 0; i--) {
230 const row = rows[i]!
231 if (row.kind === 'hunk') continue
232 if (isOld ? row.kind === 'del' && row.oldNo === end : row.kind !== 'del' && row.newNo === end) return i
233 }
234 return undefined
235}
236
237function coversRow(comment: ReviewComment, row: DiffRow): boolean {
238 const m = /^(old )?L(\d+)(?:-(\d+))?$/.exec(comment.lines)
239 if (!m || row.kind === 'hunk') return false
240 const no = m[1] ? (row.kind === 'del' ? row.oldNo : undefined) : row.kind === 'del' ? undefined : row.newNo
241 return no !== undefined && no >= Number(m[2]) && no <= Number(m[3] ?? m[2])
242}
243
244// ── Moving around ─────────────────────────────────────────────────────────
245
246// The first row of each mouse region as last drawn, to scroll a row into view.
247let blockStarts: number[] = []
248
249async function scrollToRow($: EngineInterface, row: number, block: 'nearest' | 'center' = 'nearest'): Promise<void> {
250 const start = [...blockStarts].reverse().find(s => s <= row)
251 try {
252 const r = await $.ui.scroll({ in: PANE, to: { key: `row-${row}` }, block })
253 if (r.deny !== undefined && start !== undefined) await $.ui.scroll({ in: PANE, to: { key: `blk-${start}` }, block })
254 } catch {
255 // Nothing drawn to scroll to.
256 }
257}
258
259function revealRow($: EngineInterface, row: number): void {
260 void scrollToRow($, row)
261}
262
263// Closing the comment box takes away the element holding the focus ring, and
264// the ring would land on the pane's last control (Send), scrolling there.
265// Park it on the toolbar's Comment, then bring the row back into view.
266async function closeComposer($: EngineInterface, row: number): Promise<void> {
267 composerText = ''
268 await update($, composer, () => null)
269 await parkFocus($, row)
270}
271
272// Whatever held the focus ring is gone (a closed box, a deleted card): park
273// it on the toolbar's Comment and bring the row back into view.
274async function parkFocus($: EngineInterface, row: number | undefined): Promise<void> {
275 await $.ui.focus({ requestId: PANE, key: 'comment-focus' }).catch(() => {})
276 if (row !== undefined) await scrollToRow($, row, 'center')
277}
278
279// What the edit box holds as the person types, drawn back as its value.
280let editText = ''
281
282async function commentRowOf($: EngineInterface, id: string): Promise<number | undefined> {
283 const { rows } = await current($)
284 const comment = (await read($, comments)).find(c => c.id === id)
285 return comment ? commentRow(comment, rows) : undefined
286}
287
288async function startEdit($: EngineInterface, id: string): Promise<void> {
289 const comment = (await read($, comments)).find(c => c.id === id)
290 if (!comment) return
291 editText = comment.text
292 await update($, composer, () => null)
293 await update($, editing, () => id)
294 void $.ui.focus({ requestId: PANE, key: `edit-${id}` }).catch(() => {})
295}
296
297async function saveEdit($: EngineInterface, id: string, text: string): Promise<void> {
298 const body = text.trim()
299 if (!body) {
300 $.ui.toast('review-pane: a comment needs text; use Delete to remove it')
301 return
302 }
303 await update($, comments, all => all.map(c => (c.id === id ? { ...c, text: body } : c)))
304 await stopEdit($, id)
305}
306
307async function stopEdit($: EngineInterface, id: string): Promise<void> {
308 editText = ''
309 await update($, editing, () => null)
310 await parkFocus($, await commentRowOf($, id))
311}
312
313async function deleteComment($: EngineInterface, id: string): Promise<void> {
314 const row = await commentRowOf($, id)
315 await update($, comments, all => all.filter(c => c.id !== id))
316 if ((await read($, editing)) === id) await update($, editing, () => null)
317 await parkFocus($, row)
318}
319
320async function moveRow($: EngineInterface, by: number): Promise<void> {
321 const list = await read($, files)
322 let next: ReviewCursor | undefined
323 await update($, cursor, c => {
324 const file = list[c.file]
325 const row = file ? landRow(rowsOf(file), c.row + by, by) : 0
326 return (next = clampCursor({ ...c, row }, list))
327 })
328 await update($, word, () => 0)
329 if (next) revealRow($, next.row)
330}
331
332async function selectFile($: EngineInterface, index: number): Promise<void> {
333 const list = await read($, files)
334 if (list.length === 0) return
335 const file = ((index % list.length) + list.length) % list.length
336 await update($, cursor, () => ({ file, row: firstChange(list[file]), anchor: null }))
337 await update($, word, () => 0)
338 await update($, composer, () => null)
339 await closePopups($)
340 void $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
341}
342
343async function moveFile($: EngineInterface, by: number): Promise<void> {
344 await selectFile($, (await read($, cursor)).file + by)
345}
346
347async function refreshFromDisk($: EngineInterface): Promise<void> {
348 const list = await read($, files)
349 const fresh = await Promise.all(list.map(f => readOrNull($, f.path)))
350 await update($, files, all =>
351 all.map((f, i) => (fresh[i] === f.after ? f : { ...f, after: fresh[i] ?? null, updatedAt: Date.now() })),
352 )
353}
354
355// ── Symbols and the language server ───────────────────────────────────────
356
357function symbolOn(rows: DiffRow[], index: number, x: number): SymbolRef | null {
358 const row = rows[index]
359 if (!row || row.kind === 'hunk') return null
360 const hit = wordAt(row.text, x)
361 if (!hit || isKeyword(hit.name)) return null
362 return { name: hit.name, row: index, column: hit.column, ...(row.kind !== 'del' && row.newNo ? { line: row.newNo } : {}) }
363}
364
365// The keyboard's symbol: the `word`-th identifier of the cursor's line.
366async function cursorSymbol($: EngineInterface): Promise<SymbolRef | null> {
367 const { c, rows } = await current($)
368 const row = rows[c.row]
369 if (!row || row.kind === 'hunk') return null
370 const words = wordsOf(row.text)
371 if (words.length === 0) return null
372 const hit = words[(await read($, word)) % words.length]!
373 return { name: hit.name, row: c.row, column: hit.column, ...(row.kind !== 'del' && row.newNo ? { line: row.newNo } : {}) }
374}
375
376// Says nothing useful: the call failed or no server is set up for the file.
377function isUnanswered(text: string, isError: boolean): boolean {
378 return isError || !text.trim() || /no (lsp|language) server|not available|no server|no results? found|^no result/i.test(text)
379}
380
381async function askServer(
382 $: EngineInterface,
383 operation: LspOperation,
384 path: string,
385 symbol: SymbolRef,
386): Promise<{ text: string; isError: boolean }> {
387 if (symbol.line === undefined) return { text: 'removed line: not in the file now', isError: true }
388 // The engine lists the LSP tool only while an LSP plugin is loaded; one
389 // installed mid-session loads with the next start.
390 const tools = await $.tool.list().catch(() => [])
391 if (!tools.some(t => t.name === 'LSP')) {
392 return { text: 'not loaded (install an LSP plugin, then restart Claude Code)', isError: true }
393 }
394 try {
395 const ran = await $.tool.call({ tool: 'LSP', operation, filePath: path, line: symbol.line, character: symbol.column })
396 if (ran.deny !== undefined) return { text: ran.deny, isError: true }
397 const text = ran.text ?? (ran.result as { result?: string } | undefined)?.result ?? ''
398 return { text: text.trim(), isError: Boolean(ran.isError) }
399 } catch (err) {
400 const text = String(err).replace(/^HooksError:\s*/, '').replace(/^review-pane:\s*/, '')
401 return { text, isError: true }
402 }
403}
404
405// What a text search of the changed files finds, said the way a hover card is.
406function searchText(operation: LspOperation, symbol: SymbolRef, file: ChangedFile, list: ChangedFile[], root: string) {
407 const sources: SourceFile[] = [
408 { path: file.path, text: file.after },
409 ...list.filter(f => f.path !== file.path).map(f => ({ path: f.path, text: f.after })),
410 { path: file.path, text: file.before },
411 ]
412 const fence = (path: string) => fenceOf(path)
413 if (operation === 'findReferences') {
414 const uses = findUses(symbol.name, sources.slice(0, -1))
415 if (uses.length === 0) return { text: `No uses of \`${symbol.name}\` in the changed files.`, isError: false }
416 const lines = uses.map(u => `${relative(u.path, root)}:${u.line} ${u.text.trim()}`)
417 return { text: `**${uses.length}** use${uses.length === 1 ? '' : 's'} of \`${symbol.name}\`\n\n\`\`\`\n${lines.join('\n')}\n\`\`\``, isError: false }
418 }
419 const found = findDeclaration(symbol.name, sources)
420 if (!found) return { text: `No declaration of \`${symbol.name}\` in the changed files.`, isError: false }
421 const body = operation === 'hover' ? found.text.trim() : found.context.join('\n')
422 return {
423 text: `\`\`\`${fence(found.path)}\n${body}\n\`\`\`\n${relative(found.path, root)}:${found.line}`,
424 isError: false,
425 }
426}
427
428async function lookUp($: EngineInterface, operation: LspOperation, symbol: SymbolRef | null): Promise<void> {
429 await update($, menu, () => null)
430 if (!symbol) {
431 $.ui.toast('review-pane: no symbol there')
432 return
433 }
434 const { list, file } = await current($)
435 if (!file) return
436 const base = { operation, symbol }
437 await update($, lsp, () => ({ ...base, text: '', source: 'lsp', isError: false, isBusy: true }))
438 const answer = await askServer($, operation, file.path, symbol)
439 let view: LspView
440 if (!isUnanswered(answer.text, answer.isError)) {
441 view = { ...base, text: answer.text, source: 'lsp', isError: false, isBusy: false }
442 } else {
443 const found = searchText(operation, symbol, file, list, await read($, cwd))
444 const reason = answer.text.split('\n')[0]?.slice(0, 120) || 'no answer'
445 view = { ...base, ...found, source: 'text', note: `text search · language server: ${reason}`, isBusy: false }
446 }
447 // Only if the card is still the one asked for (a click elsewhere closed it).
448 await update($, lsp, cur => (cur && cur.symbol.row === symbol.row && cur.operation === operation ? view : cur))
449}
450
451async function runLsp($: EngineInterface, operation: LspOperation): Promise<void> {
452 await lookUp($, operation, await cursorSymbol($))
453}
454
455// ── The mouse ─────────────────────────────────────────────────────────────
456
457// Where a press went down, so a drag knows its other end and a release knows
458// it was a click. A reload mid-drag forgets it, which only ends that drag.
459let press: { anchor: number; row: number; x: number; isShift: boolean; isDragged: boolean } | undefined
460
461async function onViewMessage($: EngineInterface, message: ViewMessage): Promise<void> {
462 const { c, file, rows } = await current($)
463 if (!file) return
464 const row = landRow(rows, message.row, 1)
465 const [lo, hi] = selectedRange(c)
466 const isInRange = c.anchor !== null && row >= lo && row <= hi
467
468 if (message.type === 'press') {
469 // A shift-click extends from the anchor, which a drag then keeps.
470 const anchor = message.shift ? (c.anchor ?? c.row) : row
471 press = { anchor, row, x: message.x, isShift: message.shift, isDragged: false }
472 await closePopups($)
473 await update($, cursor, cur => ({ ...cur, row, anchor: anchor === row ? null : anchor }))
474 await update($, word, () => 0)
475 return
476 }
477 if (message.type === 'drag') {
478 if (!press || row === press.row) return
479 press = { ...press, row, isDragged: true }
480 await update($, cursor, cur => ({ ...cur, row, anchor: row === press!.anchor ? null : press!.anchor }))
481 return
482 }
483 if (message.type === 'release') {
484 const done = press
485 press = undefined
486 if (!done) return
487 if (done.isDragged) {
488 await update($, cursor, cur => ({ ...cur, row, anchor: row === done.anchor ? null : done.anchor }))
489 return
490 }
491 // A plain click on a name: what it is, as an editor's hover shows it.
492 if (!done.isShift) {
493 const symbol = symbolOn(rows, done.row, done.x)
494 if (symbol) await lookUp($, 'hover', symbol)
495 }
496 return
497 }
498 if (message.type === 'context') {
499 press = undefined
500 await update($, lsp, () => null)
501 const [mlo, mhi] = isInRange ? [lo, hi] : [row, row]
502 if (!isInRange) await update($, cursor, cur => ({ ...cur, row, anchor: null }))
503 await update($, menu, () => ({ row: isInRange ? hi : row, lo: mlo, hi: mhi, symbol: symbolOn(rows, row, message.x) }))
504 return
505 }
506 // The comment mark or the "Comment" label: the range when inside it.
507 press = undefined
508 await (isInRange ? openComposer($, lo, hi) : openComposer($, row, row))
509}
510
511// ── Restoring what came before ────────────────────────────────────────────
512
513type PastEdit = { tool: string; input: Record<string, unknown>; result?: unknown }
514
515// The file's content before an edit, as the tool's record kept it, or the
516// edit reversed from `after` when the record has none.
517function beforeOf(edit: PastEdit, after: string | null): string | null {
518 const record = edit.result as { originalFile?: string | null; type?: string } | undefined
519 if (record && 'originalFile' in record) return record.type === 'create' ? null : (record.originalFile ?? null)
520 if (edit.tool === 'Write') return null
521 const oldString = edit.input.old_string
522 const newString = edit.input.new_string
523 if (typeof oldString !== 'string' || typeof newString !== 'string' || after === null) return after
524 return edit.input.replace_all === true ? after.split(newString).join(oldString) : after.replace(newString, oldString)
525}
526
527// Rebuilds the list from the conversation's earlier file-tool calls: after a
528// restart or a resume the session's state starts empty though the edits stand.
529async function backfill($: EngineInterface): Promise<void> {
530 if ((await read($, files)).length > 0) return
531 const rows = await $.session.messages().catch(() => [])
532 if (!Array.isArray(rows)) return
533 const firstEdit = new Map<string, PastEdit>()
534 const counts = new Map<string, number>()
535 for (const row of rows) {
536 for (const use of row.toolUses ?? []) {
537 if (!FILE_TOOLS.test(use.tool) || use.isError) continue
538 const path = use.input.file_path ?? use.input.notebook_path
539 if (typeof path !== 'string') continue
540 if (!firstEdit.has(path)) firstEdit.set(path, use)
541 counts.set(path, (counts.get(path) ?? 0) + 1)
542 }
543 }
544 if (firstEdit.size === 0) return
545 const found: ChangedFile[] = []
546 for (const [path, edit] of firstEdit) {
547 const after = await readOrNull($, path)
548 found.push({ path, before: beforeOf(edit, after), after, edits: counts.get(path) ?? 1, updatedAt: Date.now() })
549 }
550 await update($, files, list => (list.length > 0 ? list : found))
551 const first = (await read($, files))[0]
552 await update($, cursor, () => ({ file: 0, row: firstChange(first), anchor: null }))
553}
554
555const COMMAND = {
556 name: 'changes',
557 description: 'Open the changes pane: review diffs from this conversation and comment on lines',
558}
559
560async function ensureCommand($: EngineInterface): Promise<void> {
561 const before = await $.command.list().catch(() => [])
562 const mine = before.find(c => c.name === COMMAND.name)
563 if (!mine || mine.plugin !== 'review-pane') await $.command.register(COMMAND)
564}
565
566async function openPane($: EngineInterface, focus: boolean): Promise<void> {
567 const opened = await $.ui.open({ id: PANE, title: TITLE, ...(focus ? { focus: true as const } : {}) })
568 if (!opened.isPlaced) $.ui.toast('review-pane: widen the terminal (or run /changes) to see the pane')
569}
570
571export const register: Register = on => {
572 on('session.start', async ($, e, next) => {
573 await update($, cwd, () => e.cwd)
574 await ensureCommand($)
575 await backfill($).catch(() => {})
576 return next(e)
577 })
578
579 // A reload or restart can leave the command unlisted; put it back each turn.
580 on('prompt.submit', async ($, e, next) => {
581 void ensureCommand($).catch(() => {})
582 return next(e)
583 })
584
585 on('command.run', { command: 'changes' }, async $ => {
586 await backfill($).catch(() => {})
587 await openPane($, true)
588 return {
589 text: 'Changes pane opened. Click a line, drag for a range, right-click for actions, click a name for its definition.',
590 }
591 })
592
593 on('session.end', async ($, e, next) => {
594 if (e.reason === 'clear') {
595 await update($, files, () => [])
596 await update($, comments, () => [])
597 await update($, cursor, () => ({ file: 0, row: 0, anchor: null }))
598 await update($, composer, () => null)
599 await closePopups($)
600 }
601 return next(e)
602 })
603
604 on('tool.call', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
605 const args = e as unknown as { file_path?: string; notebook_path?: string }
606 const path = args.file_path ?? args.notebook_path
607 if (!path) return next(e)
608 const known = (await read($, files)).find(f => f.path === path)
609 const before = known ? known.before : await readOrNull($, path)
610 const ran = await next(e)
611 if (ran.deny !== undefined || ran.isError) return ran
612 const after = await readOrNull($, path)
613 const isFirst = (await read($, files)).length === 0
614 await update($, files, list => {
615 const i = list.findIndex(f => f.path === path)
616 const entry: ChangedFile = {
617 path,
618 before: i >= 0 ? list[i]!.before : before,
619 after,
620 edits: (i >= 0 ? list[i]!.edits : 0) + 1,
621 updatedAt: Date.now(),
622 }
623 return i >= 0 ? list.map((f, k) => (k === i ? entry : f)) : [...list, entry]
624 })
625 if (isFirst) {
626 const row = firstChange((await read($, files))[0])
627 await update($, cursor, () => ({ file: 0, row, anchor: null }))
628 void openPane($, false)
629 }
630 return ran
631 })
632
633 on('turn.start', async ($, e, next) => {
634 await update($, isTurnRunning, () => true)
635 return next(e)
636 })
637
638 // A step that starts after a steer has read it.
639 on('turn.step', async function* ($, e, next) {
640 if (await read($, isSteerPending)) await update($, isSteerPending, () => false)
641 return yield* next(e)
642 })
643
644 on('turn.complete', async ($, e, next) => {
645 const done = await next(e)
646 if ((e as { agentId?: string }).agentId) return done
647 await update($, isTurnRunning, () => false)
648 if (await read($, isSteerPending)) {
649 // The turn ended before any step read the steer: ask for it explicitly.
650 await update($, isSteerPending, () => false)
651 void $.prompt.submit({
652 text: 'Please address the review comments I sent above.',
653 asUser: true,
654 })
655 }
656 return done
657 })
658
659 on('ui.message', { requestId: PANE }, async ($, e) => {
660 if (e.element.startsWith('files-')) {
661 const message = e.data as FileListMessage
662 if (message.type === 'file') await selectFile($, message.index)
663 if (message.type === 'send') await sendComments($)
664 return {}
665 }
666 await onViewMessage($, e.data as ViewMessage)
667 return {}
668 })
669
670 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
671 const elements = $.ui.resolve(e)
672 const { Box, Text, Button } = elements
673 const Input = 'Input' in elements ? elements.Input : undefined
674 const Code = 'Code' in elements ? elements.Code : undefined
675 const Markdown = 'Markdown' in elements ? elements.Markdown : undefined
676 const Client = 'Client' in elements ? elements.Client : undefined
677 const highlight = (await read($, isHighlighted)) && Code !== undefined
678 const stored = await read($, lsp)
679 // A card kept from an older version of the pane has another shape.
680 const card = stored && typeof stored.symbol === 'object' ? stored : null
681 const contextMenu = await read($, menu)
682 const box = await read($, composer)
683 const list = await read($, files)
684 const drafts = await read($, comments)
685 const root = await read($, cwd)
686 const running = await read($, isTurnRunning)
687 const width = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
688
689 if (list.length === 0) {
690 return (
691 <Box flexDirection="column">
692 <Text dimColor>No file changes in this conversation yet.</Text>
693 <Text dimColor>Edits Claude makes with Edit/Write show up here.</Text>
694 </Box>
695 )
696 }
697
698 const c = clampCursor(await read($, cursor), list)
699 const file = list[c.file]!
700 const allRows = rowsOf(file)
701 const rows = allRows.slice(0, MAX_ROWS)
702 const [lo, hi] = selectedRange(c)
703 const isRange = c.anchor !== null
704 const fileDrafts = drafts.filter(d => d.path === file.path)
705 const gutter = String(Math.max(1, ...rows.map(r => (r.kind === 'hunk' ? 0 : (r.newNo ?? r.oldNo ?? 0))))).length
706 const target = describeLines(rows.slice(lo, hi + 1))
707 const keySymbol = await cursorSymbol($)
708
709 const fileList: FileListProps = {
710 selected: c.file,
711 isRunning: running,
712 files: list.map((f): FileEntry => {
713 const { added, removed } = countChanges(rowsOf(f))
714 const shown = relative(f.path, root)
715 const cut = shown.lastIndexOf('/') + 1
716 return {
717 status: f.before === null ? 'A' : f.after === null ? 'D' : 'M',
718 dir: shown.slice(0, cut),
719 name: shown.slice(cut),
720 added,
721 removed,
722 comments: drafts.filter(d => d.path === f.path).length,
723 }
724 }),
725 }
726
727 const fileButtons = list.map((f, i) => {
728 const { added, removed } = countChanges(rowsOf(f))
729 const status = f.before === null ? 'A' : f.after === null ? 'D' : 'M'
730 const count = drafts.filter(d => d.path === f.path).length
731 const label = `${i === c.file ? '▸' : ' '} ${status} ${relative(f.path, root)} +${added} -${removed}${count ? ` ✎${count}` : ''}`
732 return (
733 <Button
734 key={`file-${i}`}
735 plain
736 dimColor={i !== c.file}
737 label={label.length > width ? `…${label.slice(label.length - width + 1)}` : label}
738 onPress={() => selectFile($, i)}
739 />
740 )
741 })
742
743 // ── What sits under a row: its comments, the comment box, a card, a menu.
744 const inline = new Map<number, RenderElement[]>()
745 const place = (row: number, element: RenderElement) => inline.set(row, [...(inline.get(row) ?? []), element])
746 const unplaced: ReviewComment[] = []
747
748 const sendLabel =
749 drafts.length === 1 ? '➤ Send comment to Claude' : `➤ Send all ${drafts.length} comments to Claude`
750 const editingId = await read($, editing)
751 const draftCard = (d: ReviewComment): RenderElement =>
752 editingId === d.id && Input ? (
753 <Box key={`draft-${d.id}`} flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1}>
754 <Text color="blue" bold>{`✎ Editing the comment on ${d.lines}`}</Text>
755 <Input
756 key={`edit-${d.id}`}
757 autoFocus
758 submitLabel="save"
759 value={editText}
760 onInput={(text: string) => {
761 editText = text
762 }}
763 onSubmit={(text: string) => saveEdit($, d.id, text)}
764 />
765 <Box flexDirection="row" gap={1}>
766 <Button key={`save-${d.id}`} variant="primary" label="Save" onPress={() => saveEdit($, d.id, editText)} />
767 <Button key={`cancel-${d.id}`} label="Cancel" onPress={() => stopEdit($, d.id)} />
768 </Box>
769 </Box>
770 ) : (
771 <Box key={`draft-${d.id}`} flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
772 <Text wrap="wrap">
773 <Text color="yellow" bold>{`✎ ${d.lines} `}</Text>
774 {d.text}
775 </Text>
776 <Box flexDirection="row" gap={2}>
777 {/* Sends every draft, on every file, as one message. */}
778 <Button key={`send-${d.id}`} plain label={sendLabel} onPress={() => sendComments($)} />
779 <Button key={`edit-btn-${d.id}`} plain dimColor label="Edit" onPress={() => startEdit($, d.id)} />
780 <Button key={`drop-${d.id}`} plain dimColor label="Delete" onPress={() => deleteComment($, d.id)} />
781 </Box>
782 </Box>
783 )
784 for (const d of fileDrafts) {
785 const at = commentRow(d, rows)
786 if (at === undefined) unplaced.push(d)
787 else place(at, draftCard(d))
788 }
789
790 if (box && Input && rows[box.hi]) {
791 place(
792 box.hi,
793 <Box key="composer" flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1}>
794 <Text color="blue" bold>{`✎ Comment on ${describeLines(rows.slice(box.lo, box.hi + 1))}`}</Text>
795 <Input
796 key="comment"
797 autoFocus
798 placeholder="Write a comment for Claude, Enter to add"
799 submitLabel="add"
800 value={composerText}
801 onInput={(text: string) => {
802 composerText = text
803 }}
804 onSubmit={(text: string) => addComment($, text)}
805 />
806 <Box flexDirection="row" gap={1}>
807 <Button key="composer-add" variant="primary" label="Add comment" onPress={() => addComment($, composerText)} />
808 <Button
809 key="composer-cancel"
810 label="Cancel"
811 onPress={() => closeComposer($, box.hi)}
812 />
813 </Box>
814 </Box>,
815 )
816 }
817
818 if (card && rows[card.symbol.row]) {
819 place(
820 card.symbol.row,
821 <Box key="card" flexDirection="column" borderStyle="round" borderColor={card.isError ? 'red' : 'cyan'} paddingX={1}>
822 <Box flexDirection="row">
823 <Box flexGrow={1}>
824 <Text bold color="cyan">{`${LSP_LABELS[card.operation]} · ${card.symbol.name}`}</Text>
825 </Box>
826 <Button key="lsp-close" plain dimColor label="✕" onPress={() => update($, lsp, () => null)} />
827 </Box>
828 {card.isBusy ? (
829 <Text dimColor>Looking it up…</Text>
830 ) : Markdown && !card.isError ? (
831 <Markdown text={card.text} />
832 ) : (
833 <Text color={card.isError ? 'red' : undefined} wrap="wrap">{card.text}</Text>
834 )}
835 {card.note && <Text dimColor wrap="truncate">{card.note}</Text>}
836 {!card.isBusy && (
837 <Box flexDirection="row" gap={1}>
838 {card.operation !== 'goToDefinition' && (
839 <Button key="card-definition" plain label="Go to definition" onPress={() => lookUp($, 'goToDefinition', card.symbol)} />
840 )}
841 {card.operation !== 'findReferences' && (
842 <Button key="card-references" plain label="Find references" onPress={() => lookUp($, 'findReferences', card.symbol)} />
843 )}
844 <Button
845 key="card-comment"
846 plain
847 label="✎ Comment"
848 onPress={() => openComposer($, card.symbol.row, card.symbol.row)}
849 />
850 </Box>
851 )}
852 </Box>,
853 )
854 }
855
856 if (contextMenu && rows[contextMenu.row]) {
857 const lines = describeLines(rows.slice(contextMenu.lo, contextMenu.hi + 1))
858 const s = contextMenu.symbol
859 place(
860 contextMenu.row,
861 <Box key="menu" flexDirection="column" borderStyle="round" paddingX={1}>
862 <Button
863 key="menu-comment"
864 plain
865 autoFocus
866 label={`✎ Comment on ${lines}`}
867 onPress={() => openComposer($, contextMenu.lo, contextMenu.hi)}
868 />
869 {s && <Button key="menu-peek" plain label={`Show definition of ${s.name}`} onPress={() => lookUp($, 'hover', s)} />}
870 {s && <Button key="menu-definition" plain label="Go to definition" onPress={() => lookUp($, 'goToDefinition', s)} />}
871 {s && <Button key="menu-references" plain label="Find references" onPress={() => lookUp($, 'findReferences', s)} />}
872 <Button key="menu-close" plain dimColor label="Cancel" onPress={() => update($, menu, () => null)} />
873 </Box>,
874 )
875 }
876
877 // ── The rows themselves: mouse regions on the terminal, split where
878 // something sits under a row; rows of Buttons elsewhere.
879 const viewRows: ViewRow[] = rows.map(row => ({
880 kind: row.kind,
881 text: expandTabs(codeSource(row.text)).slice(0, Math.max(width, 40)),
882 no: row.kind === 'hunk' ? '' : String(row.newNo ?? row.oldNo ?? ''),
883 hasComment: fileDrafts.some(d => coversRow(d, row)),
884 }))
885 const viewChars = viewRows.reduce((n, r) => n + r.text.length + r.no.length + 8, 0)
886 const useMouse = Client !== undefined && e.surface === 'terminal' && viewChars <= MAX_VIEW_CHARS
887 const body: RenderElement[] = []
888
889 if (useMouse && Client) {
890 const starts: number[] = []
891 let start = 0
892 const flush = (end: number) => {
893 if (end <= start) return
894 const props: ViewProps = {
895 start,
896 rows: viewRows.slice(start, end),
897 cursor: c.row,
898 lo: isRange ? lo : -1,
899 hi: isRange ? hi : -1,
900 rangeLabel: `✎ Comment on ${target}`,
901 path: file.path,
902 isHighlighted: highlight,
903 gutter,
904 }
905 starts.push(start)
906 body.push(
907 <Box key={`blk-${start}`} flexDirection="column">
908 <Client key={`diff-v${VIEW_VERSION}-${start}`} module="./diff-view.tsx" props={props} height={end - start} />
909 </Box>,
910 )
911 start = end
912 }
913 for (let i = 0; i < viewRows.length; i++) {
914 const under = inline.get(i)
915 if (under || i + 1 - start >= BLOCK_ROWS) {
916 flush(i + 1)
917 if (under) body.push(...under)
918 }
919 }
920 flush(viewRows.length)
921 blockStarts = starts
922 } else {
923 blockStarts = []
924 rows.forEach((row, i) => {
925 const isSelected = i >= lo && i <= hi
926 const isCursor = i === c.row
927 const no = row.kind === 'hunk' ? ' '.repeat(gutter) : String(row.newNo ?? row.oldNo ?? '').padStart(gutter)
928 const color = row.kind === 'add' ? 'green' : row.kind === 'del' ? 'red' : row.kind === 'hunk' ? 'cyan' : undefined
929 body.push(
930 <Box key={`line-${i}`} flexDirection="row">
931 <Button
932 key={`row-${i}`}
933 plain
934 dimColor={!isCursor}
935 label={`${isCursor ? '›' : ' '}${no}`}
936 onPress={() => update($, cursor, cur => ({ ...cur, row: landRow(rows, i, 1), anchor: null }))}
937 />
938 {highlight && Code !== undefined && row.kind !== 'hunk' ? (
939 <>
940 <Text color={isSelected ? 'yellow' : undefined}>{isSelected ? '▌' : ' '}</Text>
941 <Box flexGrow={1}>
942 <Code source={codeSource(row.text)} path={file.path} wrap="truncate-end" />
943 </Box>
944 </>
945 ) : (
946 <Text color={color} dimColor={row.kind === 'ctx' && !isSelected} inverse={isSelected} wrap="truncate">
947 {` ${row.text}`}
948 </Text>
949 )}
950 </Box>,
951 )
952 body.push(...(inline.get(i) ?? []))
953 })
954 }
955
956 return (
957 <Box flexDirection="column">
958 {Client && e.surface === 'terminal' ? (
959 <Client key={`files-v${VIEW_VERSION}`} module="./file-list.tsx" props={fileList} height={list.length + 1} />
960 ) : (
961 <Box flexDirection="column">
962 <Text bold>
963 {`${list.length} file${list.length === 1 ? '' : 's'} changed`}
964 {running ? ' · Claude is working (comments will steer)' : ''}
965 </Text>
966 {fileButtons}
967 </Box>
968 )}
969 <Text dimColor>{'─'.repeat(width)}</Text>
970 <Box flexDirection="row" gap={1} flexWrap="wrap">
971 {/* Sending sits up here, by the file list's count, and on each draft. */}
972 {drafts.length > 0 && (
973 <Button key="send" hotkey="s" variant="primary" label={`➤ Send ${drafts.length}`} onPress={() => sendComments($)} />
974 )}
975 {drafts.length > 0 && (
976 <Button key="clear" hotkey="x" plain dimColor label="discard all" onPress={() => update($, comments, () => [])} />
977 )}
978 <Button key="comment-focus" hotkey="c" plain label={`✎ Comment on ${target}`} onPress={() => commentOnSelection($)} />
979 <Button key="prev-file" hotkey="p" plain dimColor label="◂ file" onPress={() => moveFile($, -1)} />
980 <Button key="next-file" hotkey="n" plain dimColor label="file ▸" onPress={() => moveFile($, 1)} />
981 <Button key="refresh" hotkey="r" plain dimColor label="refresh" onPress={() => refreshFromDisk($)} />
982 {Code && (
983 <Button
984 key="highlight"
985 hotkey="t"
986 plain
987 dimColor
988 label={highlight ? 'plain' : 'highlight'}
989 onPress={() => update($, isHighlighted, was => !was)}
990 />
991 )}
992 {/* Keyboard only: the mouse does these on the lines themselves. */}
993 <Button key="up" hotkey="k" plain dimColor label="↑" onPress={() => moveRow($, -1)} />
994 <Button key="down" hotkey="j" plain dimColor label="↓" onPress={() => moveRow($, 1)} />
995 <Button
996 key="mark"
997 hotkey="v"
998 plain
999 dimColor
1000 label={isRange ? 'unmark' : 'mark'}
1001 onPress={() => update($, cursor, cur => ({ ...cur, anchor: cur.anchor === null ? cur.row : null }))}
1002 />
1003 <Button key="next-symbol" hotkey="w" plain dimColor label={`word: ${keySymbol?.name ?? '–'}`} onPress={() => update($, word, n => n + 1)} />
1004 <Button key="hover" hotkey="h" plain dimColor label="def" onPress={() => runLsp($, 'hover')} />
1005 <Button key="definition" hotkey="g" plain dimColor label="go" onPress={() => runLsp($, 'goToDefinition')} />
1006 <Button key="references" hotkey="f" plain dimColor label="refs" onPress={() => runLsp($, 'findReferences')} />
1007 </Box>
1008 {useMouse && (
1009 <Text dimColor wrap="truncate">click a name for its definition · drag to select · right-click for actions</Text>
1010 )}
1011 {rows.length === 0 && <Text dimColor>No textual changes (file identical to its original).</Text>}
1012 {body}
1013 {allRows.length > MAX_ROWS && <Text dimColor>{`… ${allRows.length - MAX_ROWS} more diff rows not shown`}</Text>}
1014 <Text dimColor>{'─'.repeat(width)}</Text>
1015 {unplaced.map(draftCard)}
1016 </Box>
1017 )
1018 })
1019}
1020hooks/diff-view.tsx 166 lines1import type { ClientModule, ClientPointerEvent } from 'claude-code'
2
3// One block of diff rows drawn with the mouse in mind: each row is one cell
4// tall, so the pointer's `y` is the row. Selection, menus and cards live in the
5// hooks module (posted here as props); this module keeps only the hover.
6
7export type ViewRow = {
8 kind: 'hunk' | 'ctx' | 'add' | 'del'
9 // Tabs already expanded, so a pointer column is a text column.
10 text: string
11 no: string
12 hasComment: boolean
13}
14
15export type ViewProps = {
16 start: number
17 rows: ViewRow[]
18 cursor: number
19 lo: number
20 hi: number
21 // What the last row of a marked range offers, e.g. "✎ Comment on L2-4".
22 rangeLabel: string
23 path: string
24 isHighlighted: boolean
25 gutter: number
26}
27
28// `x` is the pointer's column within the line's text (0 at its first cell),
29// negative over the gutter.
30export type ViewMessage =
31 | { type: 'press'; row: number; x: number; shift: boolean }
32 | { type: 'drag'; row: number }
33 | { type: 'release'; row: number; x: number }
34 | { type: 'context'; row: number; x: number }
35 | { type: 'comment'; row: number }
36
37type ViewState = { hover?: number; isDown?: boolean }
38
39const HOVER_LABEL = ' ✎ Comment '
40
41// Cells before a line's text: cursor mark, number, comment mark, range bar.
42// Added and removed lines show by the colour of their number, not a sign.
43export function textStart(gutter: number): number {
44 return gutter + 3
45}
46
47const DiffView: ClientModule<ViewProps, ViewState> = (props, surface) => {
48 const { Box, Text, Code } = surface.elements
49 const state = surface.state ?? {}
50 const isRange = props.lo >= 0
51 const start = textStart(props.gutter)
52
53 const labelOf = (i: number): string => {
54 const at = props.start + i
55 const row = props.rows[i]
56 if (!row || row.kind === 'hunk') return ''
57 if (isRange && at === props.hi) return ` ${props.rangeLabel} `
58 return state.hover === i && !(isRange && at >= props.lo && at <= props.hi) ? HOVER_LABEL : ''
59 }
60 const onLabel = (e: ClientPointerEvent): boolean => {
61 const label = labelOf(e.y)
62 return label !== '' && surface.columns > 0 && e.x >= surface.columns - label.length
63 }
64 const inBlock = (e: ClientPointerEvent) => e.y >= 0 && e.y < props.rows.length
65 const rowAt = (e: ClientPointerEvent) => props.start + e.y
66
67 surface.onPointer(e => {
68 if (e.type === 'leave') {
69 if (state.hover !== undefined) surface.setState({ ...state, hover: undefined })
70 return
71 }
72 if (e.type === 'move' && !e.button) {
73 const hover = inBlock(e) ? e.y : undefined
74 if (hover !== state.hover) surface.setState({ ...state, hover })
75 return
76 }
77 if (e.type === 'down' && inBlock(e)) {
78 const row = rowAt(e)
79 if (e.button === 'right') {
80 surface.post({ type: 'context', row, x: e.x - start } satisfies ViewMessage)
81 return
82 }
83 if (e.button !== 'left') return
84 // The comment mark in the gutter, or the label at the line's end.
85 if (onLabel(e) || (e.x > props.gutter && e.x < props.gutter + 2)) {
86 surface.post({ type: 'comment', row } satisfies ViewMessage)
87 return
88 }
89 surface.setState({ ...state, isDown: true })
90 surface.post({ type: 'press', row, x: e.x - start, shift: Boolean(e.shift) } satisfies ViewMessage)
91 return
92 }
93 // Held: moves past the block's edges still arrive, so a drag spans blocks.
94 if (e.type === 'move' && e.button === 'left' && state.isDown) {
95 surface.post({ type: 'drag', row: rowAt(e) } satisfies ViewMessage)
96 return
97 }
98 if (e.type === 'up' && state.isDown) {
99 surface.setState({ ...state, isDown: false })
100 surface.post({ type: 'release', row: rowAt(e), x: e.x - start } satisfies ViewMessage)
101 }
102 })
103
104 return (
105 <Box flexDirection="column">
106 {props.rows.map((row, i) => {
107 const at = props.start + i
108 const isSelected = isRange && at >= props.lo && at <= props.hi
109 const isCursor = at === props.cursor
110 const isHover = state.hover === i
111 const label = labelOf(i)
112 const textWidth = label && surface.columns > 0 ? Math.max(1, surface.columns - start - label.length) : undefined
113 const color = row.kind === 'add' ? 'green' : row.kind === 'del' ? 'red' : undefined
114 const gutter = (
115 <Text color={color} dimColor={!color && !isCursor && !isHover} bold={isCursor}>
116 {`${isCursor ? '›' : ' '}${row.no.padStart(props.gutter)}`}
117 </Text>
118 )
119 const mark = <Text color="yellow">{row.hasComment ? '✎' : ' '}</Text>
120 const bar = <Text color="yellow">{isSelected ? '▌' : ' '}</Text>
121 const tail = label ? (
122 <Text color="blue" bold inverse={isHover}>
123 {label}
124 </Text>
125 ) : null
126 if (row.kind === 'hunk') {
127 return (
128 <Box flexDirection="row" height={1}>
129 {gutter}
130 {mark}
131 {bar}
132 <Text color="cyan" wrap="truncate">{` ${row.text}`}</Text>
133 </Box>
134 )
135 }
136 const text = props.isHighlighted ? (
137 <Code source={row.text || ' '} path={props.path} wrap="truncate-end" />
138 ) : (
139 <Text color={color} dimColor={row.kind === 'ctx' && !isSelected && !isHover} wrap="truncate">
140 {row.text || ' '}
141 </Text>
142 )
143 return (
144 <Box flexDirection="row" height={1}>
145 {gutter}
146 {mark}
147 {bar}
148 {textWidth === undefined ? (
149 <Box flexGrow={1} height={1}>
150 {text}
151 </Box>
152 ) : (
153 <Box width={textWidth} height={1}>
154 {text}
155 </Box>
156 )}
157 {tail}
158 </Box>
159 )
160 })}
161 </Box>
162 )
163}
164
165export default DiffView
166hooks/file-list.tsx 112 lines1import type { ClientModule } from 'claude-code'
2
3// The changed files as a list the mouse picks from, each part in its own
4// colour: status, folder (dim) and name, a change bar, +added −removed, and
5// the comments left on it. Row 0 is the summary, the files follow.
6
7export type FileEntry = {
8 status: 'A' | 'M' | 'D'
9 dir: string
10 name: string
11 added: number
12 removed: number
13 comments: number
14}
15
16export type FileListProps = {
17 files: FileEntry[]
18 selected: number
19 isRunning: boolean
20}
21
22export type FileListMessage = { type: 'file'; index: number } | { type: 'send' }
23
24type FileListState = { hover?: number; isOnSend?: boolean }
25
26const STATUS_COLOR = { A: 'green', M: 'yellow', D: 'red' } as const
27const BAR_CELLS = 5
28
29function bar(added: number, removed: number): { plus: number; minus: number; rest: number } {
30 const total = added + removed
31 if (total === 0) return { plus: 0, minus: 0, rest: BAR_CELLS }
32 const cells = Math.min(BAR_CELLS, total)
33 const plus = Math.round((added / total) * cells)
34 return { plus, minus: cells - plus, rest: BAR_CELLS - cells }
35}
36
37const FileList: ClientModule<FileListProps, FileListState> = (props, surface) => {
38 const { Box, Text } = surface.elements
39 const state = surface.state ?? {}
40 const added = props.files.reduce((n, f) => n + f.added, 0)
41 const removed = props.files.reduce((n, f) => n + f.removed, 0)
42 const comments = props.files.reduce((n, f) => n + f.comments, 0)
43 // The stats column, as wide as the widest file's.
44 const statWidth = Math.max(...props.files.map(f => `+${f.added} −${f.removed}`.length))
45
46 // The summary row's send link spans these columns.
47 const summary = `${props.files.length} file${props.files.length === 1 ? '' : 's'} changed `
48 const sendStart = summary.length + `+${added} −${removed}`.length + 3
49 const sendText = `➤ Send ${comments} comment${comments === 1 ? '' : 's'} to Claude`
50 const isOverSend = (x: number, y: number) => comments > 0 && y === 0 && x >= sendStart && x < sendStart + sendText.length
51
52 surface.onPointer(e => {
53 const index = e.y - 1
54 if (e.type === 'leave' || (e.type === 'move' && !e.button)) {
55 const hover = e.type === 'move' && index >= 0 && index < props.files.length ? index : undefined
56 const isOnSend = e.type === 'move' && isOverSend(e.x, e.y)
57 if (hover !== state.hover || isOnSend !== Boolean(state.isOnSend)) surface.setState({ ...state, hover, isOnSend })
58 return
59 }
60 if (e.type !== 'down' || e.button !== 'left') return
61 if (isOverSend(e.x, e.y)) surface.post({ type: 'send' } satisfies FileListMessage)
62 else if (index >= 0 && index < props.files.length) surface.post({ type: 'file', index } satisfies FileListMessage)
63 })
64
65 return (
66 <Box flexDirection="column">
67 <Box flexDirection="row" height={1}>
68 <Text bold>{summary}</Text>
69 <Text color="green" bold>{`+${added}`}</Text>
70 <Text> </Text>
71 <Text color="red" bold>{`−${removed}`}</Text>
72 {comments > 0 && <Text>{' '}</Text>}
73 {comments > 0 && (
74 <Text color="yellow" bold inverse={state.isOnSend}>
75 {sendText}
76 </Text>
77 )}
78 {props.isRunning && <Text color="magenta">{' ● Claude is working (comments will steer)'}</Text>}
79 </Box>
80 {props.files.map((f, i) => {
81 const isSelected = i === props.selected
82 const isHover = state.hover === i
83 const b = bar(f.added, f.removed)
84 const stat = `+${f.added} −${f.removed}`
85 return (
86 <Box flexDirection="row" height={1}>
87 <Text color="blue" bold>{isSelected ? '▌' : ' '}</Text>
88 <Text color={STATUS_COLOR[f.status]} bold>{` ${f.status} `}</Text>
89 <Box flexGrow={1} height={1}>
90 <Text wrap="truncate-start" underline={isHover && !isSelected}>
91 <Text dimColor>{f.dir}</Text>
92 <Text bold={isSelected} color={isSelected ? 'blue' : undefined}>{f.name}</Text>
93 </Text>
94 </Box>
95 <Text color="yellow">{f.comments ? ` ✎${f.comments}` : ''}</Text>
96 <Text>{' '.repeat(Math.max(1, statWidth - stat.length + 2))}</Text>
97 <Text color="green">{`+${f.added}`}</Text>
98 <Text> </Text>
99 <Text color="red">{`−${f.removed}`}</Text>
100 <Text> </Text>
101 <Text color="green">{'■'.repeat(b.plus)}</Text>
102 <Text color="red">{'■'.repeat(b.minus)}</Text>
103 <Text dimColor>{'■'.repeat(b.rest)}</Text>
104 </Box>
105 )
106 })}
107 </Box>
108 )
109}
110
111export default FileList
112hooks/diff.ts 166 lines1export type DiffRow =
2 | { kind: 'hunk'; text: string }
3 | { kind: 'ctx' | 'add' | 'del'; text: string; oldNo?: number; newNo?: number }
4
5type Op = { kind: 'ctx' | 'add' | 'del'; text: string; oldNo?: number; newNo?: number }
6
7const MAX_EDIT_DISTANCE = 4000
8
9// A file's lines; the newline that ends the last one starts no line of its own.
10export function splitLines(text: string | null): string[] {
11 return text ? text.replace(/\n$/, '').split('\n') : []
12}
13
14// Myers' O(ND) diff on the middle left after trimming the common prefix and
15// suffix; past MAX_EDIT_DISTANCE the middle is shown as one replacement.
16function middleOps(a: string[], b: string[]): ('=' | '-' | '+')[] {
17 const n = a.length
18 const m = b.length
19 if (n === 0) return b.map(() => '+')
20 if (m === 0) return a.map(() => '-')
21 const max = Math.min(n + m, MAX_EDIT_DISTANCE)
22 const offset = max + 1
23 const v = new Int32Array(2 * max + 3)
24 const trace: Int32Array[] = []
25 let found = -1
26 for (let d = 0; d <= max && found < 0; d++) {
27 trace.push(v.slice())
28 for (let k = -d; k <= d; k += 2) {
29 let x =
30 k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!)
31 ? v[offset + k + 1]!
32 : v[offset + k - 1]! + 1
33 let y = x - k
34 while (x < n && y < m && a[x]! === b[y]!) {
35 x++
36 y++
37 }
38 v[offset + k] = x
39 if (x >= n && y >= m) {
40 found = d
41 break
42 }
43 }
44 }
45 if (found < 0) return [...a.map(() => '-' as const), ...b.map(() => '+' as const)]
46
47 const ops: ('=' | '-' | '+')[] = []
48 let x = n
49 let y = m
50 for (let d = found; d > 0; d--) {
51 const prev = trace[d]!
52 const k = x - y
53 const prevK =
54 k === -d || (k !== d && prev[offset + k - 1]! < prev[offset + k + 1]!) ? k + 1 : k - 1
55 const prevX = prev[offset + prevK]!
56 const prevY = prevX - prevK
57 while (x > prevX && y > prevY) {
58 ops.push('=')
59 x--
60 y--
61 }
62 if (x === prevX) {
63 ops.push('+')
64 y--
65 } else {
66 ops.push('-')
67 x--
68 }
69 }
70 while (x > 0 && y > 0) {
71 ops.push('=')
72 x--
73 y--
74 }
75 return ops.reverse()
76}
77
78function allOps(a: string[], b: string[]): Op[] {
79 let start = 0
80 while (start < a.length && start < b.length && a[start] === b[start]) start++
81 let endA = a.length
82 let endB = b.length
83 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
84 endA--
85 endB--
86 }
87 const marks: ('=' | '-' | '+')[] = [
88 ...a.slice(0, start).map(() => '=' as const),
89 ...middleOps(a.slice(start, endA), b.slice(start, endB)),
90 ...a.slice(endA).map(() => '=' as const),
91 ]
92 const ops: Op[] = []
93 let i = 0
94 let j = 0
95 for (const mark of marks) {
96 if (mark === '=') {
97 ops.push({ kind: 'ctx', text: a[i]!, oldNo: i + 1, newNo: j + 1 })
98 i++
99 j++
100 } else if (mark === '-') {
101 ops.push({ kind: 'del', text: a[i]!, oldNo: i + 1 })
102 i++
103 } else {
104 ops.push({ kind: 'add', text: b[j]!, newNo: j + 1 })
105 j++
106 }
107 }
108 return ops
109}
110
111// Unified-diff rows with `context` lines around each change.
112export function diffRows(before: string | null, after: string | null, context = 3): DiffRow[] {
113 const ops = allOps(splitLines(before), splitLines(after))
114 const keep = new Uint8Array(ops.length)
115 ops.forEach((op, i) => {
116 if (op.kind === 'ctx') return
117 for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) keep[k] = 1
118 })
119 const rows: DiffRow[] = []
120 let i = 0
121 while (i < ops.length) {
122 if (!keep[i]) {
123 i++
124 continue
125 }
126 let end = i
127 while (end < ops.length && keep[end]) end++
128 const hunk = ops.slice(i, end)
129 const oldStart = hunk.find(op => op.oldNo !== undefined)?.oldNo ?? 0
130 const newStart = hunk.find(op => op.newNo !== undefined)?.newNo ?? 0
131 const oldCount = hunk.filter(op => op.kind !== 'add').length
132 const newCount = hunk.filter(op => op.kind !== 'del').length
133 rows.push({ kind: 'hunk', text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` })
134 rows.push(...hunk)
135 i = end
136 }
137 return rows
138}
139
140export function countChanges(rows: DiffRow[]): { added: number; removed: number } {
141 let added = 0
142 let removed = 0
143 for (const row of rows) {
144 if (row.kind === 'add') added++
145 else if (row.kind === 'del') removed++
146 }
147 return { added, removed }
148}
149
150// "L12-15" on the new side, or "old L8-9" for a selection of removed lines only.
151export function describeLines(rows: DiffRow[]): string {
152 const span = (nums: number[]) => {
153 const lo = Math.min(...nums)
154 const hi = Math.max(...nums)
155 return lo === hi ? `L${lo}` : `L${lo}-${hi}`
156 }
157 const newNos = rows.flatMap(r => (r.kind !== 'hunk' && r.newNo !== undefined ? [r.newNo] : []))
158 if (newNos.length > 0) return span(newNos)
159 const oldNos = rows.flatMap(r => (r.kind !== 'hunk' && r.oldNo !== undefined ? [r.oldNo] : []))
160 return oldNos.length > 0 ? `old ${span(oldNos)}` : 'hunk'
161}
162
163export function rowPrefix(row: DiffRow): string {
164 return row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : row.kind === 'ctx' ? ' ' : ''
165}
166hooks/symbols.ts 96 lines1// Identifiers on a line, and the text search the pane falls back on when no
2// language server answers: where a name is declared, and where it is used.
3
4export const TAB_WIDTH = 4
5
6export type Word = { name: string; column: number }
7
8export function wordsOf(text: string): Word[] {
9 return [...text.matchAll(/[A-Za-z_$][\w$]*/g)].map(m => ({ name: m[0], column: (m.index ?? 0) + 1 }))
10}
11
12export function expandTabs(text: string): string {
13 return text.replace(/\t/g, ' '.repeat(TAB_WIDTH))
14}
15
16// The word under display column `x` (0-based, tabs expanded), if any.
17export function wordAt(text: string, x: number): Word | undefined {
18 if (x < 0) return undefined
19 let display = 0
20 let index = -1
21 for (let i = 0; i < text.length; i++) {
22 const width = text[i] === '\t' ? TAB_WIDTH : 1
23 if (x < display + width) {
24 index = i
25 break
26 }
27 display += width
28 }
29 if (index < 0) return undefined
30 return wordsOf(text).find(w => index >= w.column - 1 && index < w.column - 1 + w.name.length)
31}
32
33const KEYWORDS = new Set([
34 'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'export', 'import', 'from',
35 'class', 'new', 'this', 'true', 'false', 'null', 'undefined', 'async', 'await', 'def', 'type',
36 'interface', 'extends', 'implements', 'public', 'private', 'static', 'void', 'string', 'number',
37])
38
39export function isKeyword(name: string): boolean {
40 return KEYWORDS.has(name)
41}
42
43function escape(name: string): string {
44 return name.replace(/[$]/g, '\\$')
45}
46
47function declarationPattern(name: string): RegExp {
48 const n = escape(name)
49 return new RegExp(
50 [
51 `\\b(?:function\\*?|class|interface|type|enum|const|let|var|def|fn|func|struct|trait|module|namespace)\\s+${n}\\b`,
52 `(?:^|[\\s,{(])${n}\\s*(?::[^=]*)?=\\s*(?:async\\s*)?(?:function|\\(|[A-Za-z_$][\\w$]*\\s*=>)`,
53 `^\\s*(?:(?:public|private|protected|static|async|readonly)\\s+)*${n}\\s*\\([^)]*\\)\\s*[:{]`,
54 ].join('|'),
55 )
56}
57
58export type SourceFile = { path: string; text: string | null }
59
60export type Found = { path: string; line: number; text: string }
61
62function linesOf(text: string | null): string[] {
63 return text === null ? [] : text.split('\n')
64}
65
66// The first declaration of `name`, searching `files` in order.
67export function findDeclaration(name: string, files: SourceFile[]): (Found & { context: string[] }) | undefined {
68 const pattern = declarationPattern(name)
69 for (const file of files) {
70 const lines = linesOf(file.text)
71 const i = lines.findIndex(line => pattern.test(line))
72 if (i >= 0) return { path: file.path, line: i + 1, text: lines[i]!, context: lines.slice(i, i + 8) }
73 }
74 return undefined
75}
76
77export function findUses(name: string, files: SourceFile[], limit = 30): Found[] {
78 const pattern = new RegExp(`(?<![\\w$])${escape(name)}(?![\\w$])`)
79 const found: Found[] = []
80 for (const file of files) {
81 linesOf(file.text).forEach((line, i) => {
82 if (found.length < limit && pattern.test(line)) found.push({ path: file.path, line: i + 1, text: line })
83 })
84 }
85 return found
86}
87
88const FENCES: Record<string, string> = {
89 ts: 'ts', tsx: 'tsx', js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', py: 'python', go: 'go', rs: 'rust',
90 rb: 'ruby', java: 'java', kt: 'kotlin', c: 'c', h: 'c', cpp: 'cpp', cs: 'csharp', sh: 'bash', md: 'md',
91}
92
93export function fenceOf(path: string): string {
94 return FENCES[path.split('.').pop() ?? ''] ?? ''
95}
96types/index.d.ts 63 lines1export type ChangedFile = {
2 path: string
3 // Content when the conversation first touched it; null when the file did not exist.
4 before: string | null
5 after: string | null
6 edits: number
7 updatedAt: number
8}
9
10export type ReviewComment = {
11 id: string
12 path: string
13 lines: string
14 snippet: string
15 text: string
16}
17
18export type ReviewCursor = { file: number; row: number; anchor: number | null }
19
20export type LspOperation = 'hover' | 'goToDefinition' | 'findReferences'
21
22// A symbol on a diff row: `line` is its line in the file as it is now (absent
23// on a removed line), `column` 1-based in the line's text.
24export type SymbolRef = { name: string; row: number; line?: number; column: number }
25
26// The answer about one symbol, drawn as a card under its row; `source` says
27// whether a language server answered or the pane searched the text itself.
28export type LspView = {
29 operation: LspOperation
30 symbol: SymbolRef
31 text: string
32 note?: string
33 source: 'lsp' | 'text'
34 isError: boolean
35 isBusy: boolean
36}
37
38// The right-click menu, drawn under `row`.
39export type ContextMenu = { row: number; lo: number; hi: number; symbol: SymbolRef | null }
40
41// The inline comment box for rows lo..hi, drawn under hi.
42export type Composer = { lo: number; hi: number }
43
44declare module 'claude-code' {
45 interface PluginState {
46 'review-pane': {
47 files: ChangedFile[]
48 comments: ReviewComment[]
49 cursor: ReviewCursor
50 cwd: string
51 isTurnRunning: boolean
52 isSteerPending: boolean
53 isHighlighted: boolean
54 word: number
55 lsp: LspView | null
56 menu: ContextMenu | null
57 composer: Composer | null
58 // The id of the comment being edited in place.
59 editing: string | null
60 }
61 }
62}
63