SLOPSHOPPER

review-pane

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

newpaneguardcommandtoastprompt
v0.2.0MITupdated 2026-10-02rudrasecure/claude-mods/review-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-pane
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./file-list.tsx │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ c: ✎ Comment on hunk p: ◂ file n: file ▸ r: ⏺ Update(src/auth.ts) │ ┃ click a name for its definition · drag to s… ⎿ Added 2 lines, removed 1 line │ ┃ No textual changes (file identical to its ⏺ Bash(bun test) │ ┃ original). ⎿ 3 pass, 1 fail │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ ⎿ review-pane: Changes pane opened. Click a line, drag for a range │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
▣ client module ./file-list.tsx ──────────────────────────────────────────────────────── c: ✎ Comment on hunk p: ◂ file n: file ▸ r: refresh t: plain click a name for its definition · drag to select · right-cl… No textual changes (file identical to its original). ────────────────────────────────────────────────────────
README

claude-mods

Mods for Claude Code by Rudra Secure.

ModWhat it does
review-paneA 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-pane

Review Claude's changes the way you review a pull request, without leaving the session.

  • Every change in the conversation. Each file Claude touches with Edit, Write, MultiEdit or NotebookEdit is listed with its status (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.
  • Readable diffs. Syntax highlighting per language; added and removed lines are marked by the colour of their line number.
  • Comment on lines. Click a line, drag or shift-click for a range, then click ✎ Comment at the end of the line or the selection. The comment box opens right under the lines. Saved comments show as cards under their lines, with Edit and Delete.
  • Send to Claude. ➤ Send (on any comment card, the file list's summary row, or the toolbar) sends all comments as one message, each with the file, the lines and the diff snippet. While Claude is working, the comments are delivered into the running turn so it can change course; otherwise they start a new turn.
  • Code intelligence. Click a name to see its definition, the way an editor's hover does. Right-click a line or a name for Comment, Show definition, Go to definition and Find references. Answers come from a language server when one is set up (see Language servers), and from a text search of the changed files otherwise.

Requirements

  • Claude Code with mods (function-hook plugins). Built and tested on 2.1.287. The mod API is early access and may change between releases.
  • The fullscreen terminal layout for the mouse (click, drag, right-click, hover). Set "tui": "fullscreen" in ~/.claude/settings.json, or pick it in /config. Elsewhere the pane still works with the keyboard and clickable line numbers.
  • A terminal at least 144 columns wide for the pane to dock beside the transcript on its own. /changes opens it at any width.

Install

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" }
}

Use

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)

DoTo
Click a file in the listShow its diff
Click a lineMove the cursor there
Drag, or shift-clickSelect a range of lines
Click ✎ Comment at the end of a hovered line or a selectionComment on it
Click a function, variable or type nameSee its definition
Right-click a line, a selection or a nameComment, show definition, go to definition, find references
Click ➤ SendSend all comments to Claude

Keyboard (once the pane has focus: click it, or open it with /changes)

KeyActionKeyAction
j / kNext / previous linen / pNext / previous file
vStart or clear a rangecComment on the line or range
sSend all commentsxDiscard all comments
wNext name on the linehDefinition of that name
gGo to its definitionfFind its references
tHighlighting on or offrRe-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.

Language servers (optional)

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:

LanguageServer (install it yourself)Claude Code plugin
TypeScript / JavaScriptnpm install -g typescript-language-server typescript@5typescript-lsp@claude-plugins-official
Pythonnpm install -g pyrightpyright-lsp@claude-plugins-official
Gogo 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 managerclangd-lsp@claude-plugins-official
Rustrustup component add rust-analyzerrust-analyzer-lsp@claude-plugins-official
Lualua-language-server from your package managerlua-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:

  • TypeScript must be 5.x. npm install -g typescript now installs TypeScript 7, the native rewrite, which typescript-language-server cannot drive.
  • A full restart. If your sessions run in the background daemon, /exit followed by claude --continue reattaches to the same process. Run claude daemon stop --any in between.
  • The 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" } }

Limitations

  • Changes made outside the file tools (for example by a shell command) are not picked up; press r to re-read listed files from disk.
  • Comments not yet sent are kept for the session only: a restart loses them.
  • Each line is highlighted on its own, so a construct spanning lines (a block comment, a multi-line string) can be coloured wrongly after its first line.
  • Right-click needs a terminal that reports it, and shift-click one that reports modifiers (tmux may not pass them through).
  • Diffs longer than 2,000 rows are cut off.

Development

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.

License

MIT © Rudra Secure

Source 6 files
hooks/register.tsx 1020 lines
1import { 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}
1020
hooks/diff-view.tsx 166 lines
1import 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
166
hooks/file-list.tsx 112 lines
1import 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
112
hooks/diff.ts 166 lines
1export 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}
166
hooks/symbols.ts 96 lines
1// 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}
96
types/index.d.ts 63 lines
1export 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