Review a local diff in a pane with inline comments and suggestions, then send them to Claude

A Claude Code mod for reviewing a local diff in a pane, tuicr-style: move through the diff, leave inline comments and suggestions, then send them all to Claude as one prompt. Faster than pushing a draft PR to review the agent's work.
In a Claude Code terminal session:
/plugin install local-review --marketplace shaharz/local-review
Answer y to add the marketplace, then pick the user scope.
Built against Claude Code 2.1.295. The function-hooks API it uses is early access, so a later Claude Code release may need an update here.
/local-review reviews uncommitted changes in the session's repo (including untracked files)./local-review staged, /local-review <commit>, /local-review <a>..<b>, /local-review branch (merge-base with origin/main to the working tree) or branch:<base>.--cwd <dir> reviews another repo or worktree.open_review tool, for example on a worktree it just changed.Click the diff once to give it the keyboard, then:
| Key | Action |
|---|---|
j / k, arrows, g / G, pgup / pgdn | move |
] / [ | next / previous file |
f | file list |
c | comment on the line |
C | comment on the file |
s | suggestion: edit the line into what it should be |
x | delete the comment on the line |
r | refresh the diff |
S | send the comments to Claude |
P | put the comments in the prompt instead |
q | close the pane |
While typing a comment: Enter saves; ctrl+g or ✕ cancel cancels (so does Enter on empty text); ctrl+u clears. Esc can't reach the pane: it hands the keyboard back to the prompt and leaves the comment open.
The diff refreshes after each Claude turn while the pane is open. Works in the terminal and the desktop app; not in VS Code or mobile.
claude plugin validate .
claude plugin test .hooks/register.tsx 239 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Review, ReviewComment, ViewerMessage, ViewerProps } from '../types'
5import { branchArgv, buildPrompt, capRows, commitArgv, parseDiff, planTarget, untrackedArgv } from './diff'
6
7const PANE = 'local-review'
8const TOOL = 'open_review'
9const MAX_UNTRACKED = 100
10const FILE_PROPS_LIMIT = 70_000
11
12const review = atom({ plugin: 'local-review', key: 'review' } as const, null)
13const comments = atom({ plugin: 'local-review', key: 'comments' } as const, [])
14const fileIndex = atom({ plugin: 'local-review', key: 'fileIndex' } as const, 0)
15const isOpen = atom({ plugin: 'local-review', key: 'isOpen' } as const, false)
16const isEditing = atom({ plugin: 'local-review', key: 'isEditing' } as const, false)
17const cancelSeq = atom({ plugin: 'local-review', key: 'cancelSeq' } as const, 0)
18
19type $ = EngineInterface
20
21async function git($: $, cwd: string, argv: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
22 return $.process.run(['git', ...argv], { cwd, timeoutMs: 60_000 })
23}
24
25async function loadReview($: $, cwd: string, target: string): Promise<Review> {
26 const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
27 if (top.exitCode !== 0) {
28 return { root: cwd, target, label: target || 'uncommitted changes', files: [], error: top.stderr.trim() }
29 }
30 const root = top.stdout.trim()
31 const name = root.split('/').pop() ?? root
32 const plan = planTarget(target)
33
34 let argv: string[]
35 let label: string
36 let withUntracked = false
37
38 if (plan.kind === 'diff') {
39 argv = plan.argv
40 label = `${plan.label} in ${name}`
41 withUntracked = plan.withUntracked
42 } else if (plan.kind === 'branch') {
43 const base = plan.base || ((await git($, root, ['rev-parse', '--verify', '-q', 'origin/main'])).exitCode === 0 ? 'origin/main' : 'main')
44 const mb = await git($, root, ['merge-base', base, 'HEAD'])
45 if (mb.exitCode !== 0) {
46 return { root, target, label: `branch vs ${base}`, files: [], error: mb.stderr.trim() }
47 }
48 const branch = (await git($, root, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
49 argv = branchArgv(mb.stdout.trim())
50 label = `${branch} vs ${base} (incl. uncommitted) in ${name}`
51 withUntracked = true
52 } else {
53 argv = commitArgv(plan.rev)
54 const subject = (await git($, root, ['log', '-1', '--format=%h %s', plan.rev])).stdout.trim()
55 label = `commit ${subject || plan.rev} in ${name}`
56 }
57
58 const out = await git($, root, argv)
59 if (out.exitCode !== 0) return { root, target, label, files: [], error: out.stderr.trim() }
60 const files = parseDiff(out.stdout)
61
62 if (withUntracked) {
63 const listed = await git($, root, ['ls-files', '--others', '--exclude-standard', '-z'])
64 const paths = listed.stdout.split('\0').filter(p => p !== '')
65 for (const path of paths.slice(0, MAX_UNTRACKED)) {
66 const one = await git($, root, untrackedArgv(path))
67 files.push(...parseDiff(one.stdout).map(f => ({ ...f, path, status: f.status === 'binary' ? f.status : ('added' as const) })))
68 }
69 }
70
71 return { root, target, label, files }
72}
73
74async function openReview($: $, cwd: string, target: string): Promise<Review> {
75 const loaded = await loadReview($, cwd, target)
76 const previous = await read($, review)
77 const isSame = previous !== null && previous.root === loaded.root && previous.target === loaded.target
78 await update($, review, () => loaded)
79 if (!isSame) {
80 await update($, comments, () => [])
81 await update($, fileIndex, () => 0)
82 }
83 await update($, isOpen, () => true)
84 void $.ui.open({ id: PANE, title: 'Review', focus: true, closeOnEscape: true, columns: 120 })
85
86 return loaded
87}
88
89async function refresh($: $): Promise<void> {
90 const current = await read($, review)
91 if (current === null) return
92 const loaded = await loadReview($, current.root, current.target)
93 await update($, review, () => loaded)
94 await update($, fileIndex, i => Math.min(i, Math.max(0, loaded.files.length - 1)))
95}
96
97async function send($: $, mode: 'submit' | 'fill'): Promise<void> {
98 const current = await read($, review)
99 const list = await read($, comments)
100 if (current === null || list.length === 0) {
101 $.ui.toast('No review comments to send yet')
102
103 return
104 }
105 const text = buildPrompt(current, list)
106 if (mode === 'submit') await $.prompt.submit({ text, asUser: true })
107 else await $.prompt.fill({ text, mode: 'append' })
108 await update($, comments, () => [])
109 $.ui.toast(mode === 'submit' ? `Sent ${list.length} review comments to Claude` : `Put ${list.length} review comments in the prompt`)
110}
111
112function summary(r: Review): string {
113 if (r.error) return `Could not load ${r.label}: ${r.error}`
114
115 return `Opened review of ${r.label}: ${r.files.length} file(s).`
116}
117
118function parseArgs(args: string): { target: string; cwd?: string } {
119 const tokens = args.trim().split(/\s+/).filter(t => t !== '')
120 const cwdAt = tokens.indexOf('--cwd')
121 if (cwdAt >= 0) {
122 const cwd = tokens[cwdAt + 1]
123 tokens.splice(cwdAt, 2)
124
125 return { target: tokens.join(' '), cwd }
126 }
127
128 return { target: tokens.join(' ') }
129}
130
131export const register: Register = on => {
132 on('session.start', async ($, e, next) => {
133 await $.command.register({
134 name: 'local-review',
135 description: 'Review a local diff with inline comments: [staged | <commit> | <a>..<b> | branch[:<base>]] [--cwd <dir>]',
136 argumentHint: '[staged|<commit>|<a>..<b>|branch[:<base>]] [--cwd <dir>]',
137 })
138 await $.tool.register({
139 name: TOOL,
140 description:
141 "Open the user's local review pane on a diff so they can leave inline comments and suggestions, which arrive later as a prompt. Use it after finishing a change the user should review. target: '' or 'uncommitted' (working tree vs HEAD, with untracked files), 'staged', a commit-ish, a range 'a..b', or 'branch' / 'branch:<base>' (merge-base with base, default origin/main, to the working tree). cwd: the repo or worktree directory, default the session's.",
142 inputSchema: {
143 type: 'object',
144 properties: {
145 target: { type: 'string', description: "What to review; default 'uncommitted'." },
146 cwd: { type: 'string', description: 'Repository or worktree directory, absolute.' },
147 },
148 },
149 isDeferred: false,
150 })
151
152 return next(e)
153 })
154
155 on('command.run', { command: 'local-review' }, async ($, e) => {
156 const { target, cwd } = parseArgs(e.args)
157 const loaded = await openReview($, cwd ?? (await $.session.cwd()), target)
158
159 return { text: summary(loaded) }
160 })
161
162 on('tool.call', { tool: 'mcp__local-review__open_review' }, async ($, e) => {
163 const target = typeof e.target === 'string' ? e.target : ''
164 const cwd = typeof e.cwd === 'string' && e.cwd !== '' ? e.cwd : await $.session.cwd()
165 const loaded = await openReview($, cwd, target)
166
167 return { result: `${summary(loaded)} The user's comments will arrive as a later prompt; don't wait for them.` }
168 })
169
170 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
171 const msg = e.data as ViewerMessage
172
173 if (msg.type === 'file') await update($, fileIndex, () => msg.index)
174 else if (msg.type === 'add') {
175 const id = crypto.randomUUID()
176 await update($, isEditing, () => false)
177 await update($, comments, list => [...list, { ...msg.comment, id } as ReviewComment])
178 } else if (msg.type === 'delete') await update($, comments, list => list.filter(c => c.id !== msg.id))
179 else if (msg.type === 'refresh') await refresh($)
180 else if (msg.type === 'send') await send($, msg.mode)
181 else if (msg.type === 'close') await $.ui.close({ id: PANE })
182 else if (msg.type === 'editing') await update($, isEditing, () => msg.on)
183
184 return next(e)
185 })
186
187 on('turn.complete', async ($, e, next) => {
188 const result = await next(e)
189 if (await read($, isOpen)) await refresh($)
190
191 return result
192 })
193
194 on('ui.close', async ($, e, next) => {
195 if (e.id === PANE && e.origin.kind === 'person' && (await read($, isEditing))) {
196 // Esc never reaches the viewer; its close while a comment is open cancels the comment instead.
197 await update($, isEditing, () => false)
198 await update($, cancelSeq, n => n + 1)
199
200 return { value: undefined }
201 }
202 if (e.id === PANE) await update($, isOpen, () => false)
203
204 return next(e)
205 })
206
207 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
208 if (e.surface === 'mobile' || e.surface === 'vscode') {
209 const { Text } = $.ui.resolve(e)
210
211 return <Text dimColor>The review pane needs the terminal or the desktop app.</Text>
212 }
213 const { Box, Text, Client } = $.ui.resolve(e)
214 const current = await read($, review)
215
216 if (current === null) return <Text dimColor>No review open. Run /local-review.</Text>
217
218 const list = await read($, comments)
219 const index = Math.min(await read($, fileIndex), Math.max(0, current.files.length - 1))
220 const file = current.files[index] ?? null
221 const props: ViewerProps = {
222 label: current.label,
223 fileIndex: index,
224 files: current.files.map(f => ({ path: f.path, status: f.status, comments: list.filter(c => c.path === f.path).length })),
225 file: file === null ? null : capRows(file, FILE_PROPS_LIMIT),
226 comments: file === null ? [] : list.filter(c => c.path === file.path),
227 total: list.length,
228 cancelSeq: await read($, cancelSeq),
229 ...(current.error === undefined ? {} : { error: current.error }),
230 }
231
232 return (
233 <Box flexDirection="column">
234 <Client key="viewer" module="./viewer.tsx" props={props} height={Math.max(6, e.props.scroll.bodyRows)} />
235 </Box>
236 )
237 })
238}
239hooks/diff.ts 171 lines1import type { FileDiff, Review, ReviewComment, Row } from '../types'
2
3const FLAGS = ['--no-color', '--no-ext-diff', '-M', '--src-prefix=a/', '--dst-prefix=b/']
4
5export type GitPlan =
6 | { kind: 'diff'; argv: string[]; withUntracked: boolean; label: string }
7 | { kind: 'branch'; base: string; label: string }
8 | { kind: 'commit'; rev: string }
9
10/** Maps a review target as typed (`staged`, `abc123`, `main..HEAD`, `branch:origin/main`) to the git call that produces it. */
11export function planTarget(target: string): GitPlan {
12 const t = target.trim()
13
14 if (t === '' || t === 'uncommitted' || t === 'wt') {
15 return { kind: 'diff', argv: ['diff', ...FLAGS, 'HEAD'], withUntracked: true, label: 'uncommitted changes' }
16 }
17 if (t === 'staged') {
18 return { kind: 'diff', argv: ['diff', ...FLAGS, '--cached'], withUntracked: false, label: 'staged changes' }
19 }
20 if (t === 'branch' || t.startsWith('branch:')) {
21 return { kind: 'branch', base: t.slice('branch:'.length) || '', label: 'branch' }
22 }
23 if (t.includes('..')) {
24 return { kind: 'diff', argv: ['diff', ...FLAGS, t], withUntracked: false, label: t }
25 }
26
27 return { kind: 'commit', rev: t }
28}
29
30export function branchArgv(mergeBase: string): string[] {
31 return ['diff', ...FLAGS, mergeBase]
32}
33
34export function commitArgv(rev: string): string[] {
35 return ['show', ...FLAGS, '--format=', '--diff-merges=first-parent', rev]
36}
37
38export function untrackedArgv(path: string): string[] {
39 return ['diff', ...FLAGS, '--no-index', '--', '/dev/null', path]
40}
41
42const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
43
44function stripPrefix(path: string): string {
45 return path.replace(/^[ab]\//, '')
46}
47
48/** Parses `git diff`/`git show` output into files of display rows. */
49export function parseDiff(text: string): FileDiff[] {
50 const files: FileDiff[] = []
51 let file: FileDiff | null = null
52 let o = 0
53 let n = 0
54 let inHunk = false
55
56 for (const line of text.split('\n')) {
57 if (line.startsWith('diff --git ')) {
58 const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line)
59 const next: FileDiff = { path: m?.[2] ?? line.slice(11), status: 'modified', rows: [] }
60 files.push(next)
61 file = next
62 inHunk = false
63 continue
64 }
65 if (file === null) continue
66
67 if (!inHunk) {
68 if (line.startsWith('new file mode')) file.status = 'added'
69 else if (line.startsWith('deleted file mode')) file.status = 'deleted'
70 else if (line.startsWith('rename from ')) {
71 file.oldPath = line.slice('rename from '.length)
72 file.status = 'renamed'
73 } else if (line.startsWith('rename to ')) file.path = line.slice('rename to '.length)
74 else if (line.startsWith('Binary files ')) {
75 file.status = 'binary'
76 file.rows.push({ k: 'note', t: 'Binary file' })
77 } else if (line.startsWith('+++ ') && line !== '+++ /dev/null') file.path = stripPrefix(line.slice(4))
78 }
79
80 const hunk = HUNK.exec(line)
81 if (hunk) {
82 o = Number(hunk[1])
83 n = Number(hunk[2])
84 inHunk = true
85 file.rows.push({ k: 'hunk', t: line })
86 continue
87 }
88 if (!inHunk) continue
89
90 if (line.startsWith('+')) file.rows.push({ k: 'add', t: line.slice(1), n: n++ })
91 else if (line.startsWith('-')) file.rows.push({ k: 'del', t: line.slice(1), o: o++ })
92 else if (line.startsWith(' ')) file.rows.push({ k: 'ctx', t: line.slice(1), o: o++, n: n++ })
93 else if (line.startsWith('\\')) continue
94 else inHunk = false
95 }
96
97 return files
98}
99
100/** Whether a comment is anchored on this row. */
101export function isOnRow(c: ReviewComment, path: string, row: Row): boolean {
102 if (c.path !== path) return false
103 if (c.side === 'new') return (row.k === 'add' || row.k === 'ctx') && row.n === c.line
104 if (c.side === 'old') return row.k === 'del' && row.o === c.line
105
106 return false
107}
108
109/** Keeps a file's rows under `limit` characters so the viewer's props stay within the Client bound. */
110export function capRows(file: FileDiff, limit: number): FileDiff {
111 let size = 0
112 const rows: Row[] = []
113
114 for (const row of file.rows) {
115 size += row.t.length + 24
116 if (size > limit) {
117 rows.push({ k: 'note', t: `… ${file.rows.length - rows.length} more lines not shown (diff too large for the pane)` })
118
119 return { ...file, rows }
120 }
121 rows.push(row)
122 }
123
124 return file
125}
126
127function inlineCode(code: string | undefined): string {
128 const text = (code ?? '').trim()
129
130 return text === '' ? '(blank line)' : '`' + text.replace(/`/g, 'ˋ') + '`'
131}
132
133function quote(text: string): string {
134 return text
135 .split('\n')
136 .map(line => ` > ${line}`)
137 .join('\n')
138}
139
140/** The prompt Claude receives: comments grouped by file, each citing its line and code. */
141export function buildPrompt(review: Review, comments: ReviewComment[]): string {
142 const byPath = new Map<string, ReviewComment[]>()
143 for (const c of comments) byPath.set(c.path, [...(byPath.get(c.path) ?? []), c])
144
145 const sections = [...byPath.entries()].map(([path, list]) => {
146 const sorted = [...list].sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
147 const items = sorted.map(c => {
148 if (c.side === 'file') return `- **File**:\n${quote(c.text)}`
149
150 const where = c.side === 'old' ? `L${c.line} (removed line)` : `L${c.line}`
151 if (c.kind === 'suggest') {
152 return `- **${where} suggestion**: replace ${inlineCode(c.code)} with:\n \`\`\`\n${c.text
153 .split('\n')
154 .map(l => ` ${l}`)
155 .join('\n')}\n \`\`\``
156 }
157
158 return `- **${where}** ${inlineCode(c.code)}\n${quote(c.text)}`
159 })
160
161 return `### ${path}\n${items.join('\n')}`
162 })
163
164 return [
165 `Review comments on ${review.label} (repo: ${review.root}).`,
166 'Address each one. For a suggestion, apply the replacement unless you disagree, and then say why.',
167 '',
168 sections.join('\n\n'),
169 ].join('\n')
170}
171hooks/viewer.tsx 312 lines1import type { ClientKeyEvent, ClientModule, ClientSurface } from 'claude-code'
2
3import type { ReviewComment, Row, ViewerMessage, ViewerProps } from '../types'
4import { isOnRow } from './diff'
5
6type Edit = { kind: 'comment' | 'suggest' | 'file'; row: number; text: string; pos: number }
7
8type State = {
9 fileIndex: number
10 cursor: number
11 top: number
12 mode: 'diff' | 'files'
13 fileCursor: number
14 edit: Edit | null
15 cancelSeq: number
16}
17
18type Line = { row: number } | { comment: ReviewComment }
19
20const SPECIAL = new Set([
21 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete',
22 'pageup', 'pagedown', 'home', 'end', 'escape',
23])
24
25const HINTS = 'j/k move ]/[ file f files c comment C file s suggest x delete r refresh S send P to prompt q close'
26
27function initial(props: ViewerProps): State {
28 return { fileIndex: props.fileIndex, cursor: 0, top: 0, mode: 'diff', fileCursor: props.fileIndex, edit: null, cancelSeq: props.cancelSeq }
29}
30
31function lines(props: ViewerProps): Line[] {
32 const file = props.file
33 if (file === null) return []
34 const out: Line[] = props.comments.filter(c => c.side === 'file').map(comment => ({ comment }))
35 file.rows.forEach((row, i) => {
36 out.push({ row: i })
37 for (const comment of props.comments) if (isOnRow(comment, file.path, row)) out.push({ comment })
38 })
39
40 return out
41}
42
43function lineOf(all: Line[], row: number): number {
44 const at = all.findIndex(l => 'row' in l && l.row === row)
45
46 return at < 0 ? 0 : at
47}
48
49function isCodeRow(row: Row | undefined): row is Row {
50 return row !== undefined && (row.k === 'add' || row.k === 'ctx' || row.k === 'del')
51}
52
53function pad(n: number | undefined, width: number): string {
54 return (n === undefined ? '' : String(n)).padStart(width)
55}
56
57const Viewer: ClientModule<ViewerProps, State> = (props, surface: ClientSurface<State>) => {
58 const { Box, Button, Text } = surface.elements
59 const s0 = surface.state
60 const rowCount = props.file?.rows.length ?? 0
61 const base = s0 !== undefined && s0.fileIndex === props.fileIndex ? s0 : initial(props)
62 const s: State = {
63 ...base,
64 cursor: Math.min(base.cursor, Math.max(0, rowCount - 1)),
65 edit: base.cancelSeq === props.cancelSeq ? base.edit : null,
66 cancelSeq: props.cancelSeq,
67 }
68 const all = lines(props)
69 const height = surface.rows > 0 ? surface.rows : 20
70 const bodyRows = Math.max(1, height - 3)
71
72 const post = (msg: ViewerMessage) => surface.post(msg)
73
74 const scrolled = (next: State): State => {
75 const at = lineOf(all, next.cursor)
76 let top = next.top
77 if (at < top) top = at
78 if (at >= top + bodyRows) top = at - bodyRows + 1
79 if (next.cursor === 0) top = 0
80
81 return { ...next, top: Math.max(0, top) }
82 }
83
84 const move = (delta: number) => {
85 const cursor = Math.max(0, Math.min(rowCount - 1, s.cursor + delta))
86 surface.setState(scrolled({ ...s, cursor }))
87 }
88
89 const cancelEdit = () => {
90 surface.setState({ ...s, edit: null })
91 post({ type: 'editing', on: false })
92 }
93
94 const startEdit = (kind: Edit['kind']) => {
95 const row = props.file?.rows[s.cursor]
96 if (kind !== 'file' && !isCodeRow(row)) return
97 const text = kind === 'suggest' && row !== undefined ? row.t : ''
98 surface.setState({ ...s, edit: { kind, row: s.cursor, text, pos: text.length } })
99 post({ type: 'editing', on: true })
100 }
101
102 const commit = (edit: Edit) => {
103 const file = props.file
104 if (file === null || edit.text.trim() === '') return
105 if (edit.kind === 'file') {
106 post({ type: 'add', comment: { path: file.path, side: 'file', kind: 'comment', text: edit.text } })
107
108 return
109 }
110 const row = file.rows[edit.row]
111 if (!isCodeRow(row)) return
112 const side = row.k === 'del' ? 'old' : 'new'
113 const line = row.k === 'del' ? row.o : row.n
114 post({ type: 'add', comment: { path: file.path, side, line, code: row.t, kind: edit.kind, text: edit.text } })
115 }
116
117 const onEditKey = (edit: Edit, key: ClientKeyEvent) => {
118 const set = (next: Edit | null) => surface.setState({ ...s, edit: next })
119 const cancel = cancelEdit
120 const { text, pos } = edit
121
122 if (key.key === 'return' || key.key === 'enter') {
123 if (edit.text.trim() === '') cancel()
124 else {
125 commit(edit)
126 set(null)
127 }
128 } else if (key.ctrl && (key.key === 'c' || key.key === 'g')) cancel()
129 else if (key.ctrl && key.key === 'u') set({ ...edit, text: '', pos: 0 })
130 else if (key.key === 'home' || (key.ctrl && key.key === 'a')) set({ ...edit, pos: 0 })
131 else if (key.key === 'end' || (key.ctrl && key.key === 'e')) set({ ...edit, pos: text.length })
132 else if (key.key === 'left') set({ ...edit, pos: Math.max(0, pos - 1) })
133 else if (key.key === 'right') set({ ...edit, pos: Math.min(text.length, pos + 1) })
134 else if (key.key === 'backspace' && pos > 0) set({ ...edit, text: text.slice(0, pos - 1) + text.slice(pos), pos: pos - 1 })
135 else if (key.key === 'delete' && pos < text.length) set({ ...edit, text: text.slice(0, pos) + text.slice(pos + 1) })
136 else if (!key.ctrl && !key.meta && (key.key === 'space' || !SPECIAL.has(key.key))) {
137 const typed = key.key === 'space' ? ' ' : key.key
138 set({ ...edit, text: text.slice(0, pos) + typed + text.slice(pos), pos: pos + typed.length })
139 }
140 }
141
142 const onFilesKey = (key: ClientKeyEvent) => {
143 const last = props.files.length - 1
144 if (key.key === 'j' || key.key === 'down') surface.setState({ ...s, fileCursor: Math.min(last, s.fileCursor + 1) })
145 else if (key.key === 'k' || key.key === 'up') surface.setState({ ...s, fileCursor: Math.max(0, s.fileCursor - 1) })
146 else if (key.key === 'return' || key.key === 'enter') {
147 surface.setState({ ...s, mode: 'diff' })
148 post({ type: 'file', index: s.fileCursor })
149 } else if (key.key === 'f' || key.key === 'q') surface.setState({ ...s, mode: 'diff' })
150 }
151
152 const onDiffKey = (key: ClientKeyEvent) => {
153 const half = Math.max(1, Math.floor(bodyRows / 2))
154 const k = key.key
155
156 if (k === 'j' || k === 'down') move(1)
157 else if (k === 'k' || k === 'up') move(-1)
158 else if (k === 'pagedown' || k === 'space' || k === ' ' || (key.ctrl && k === 'd')) move(half)
159 else if (k === 'pageup' || (key.ctrl && k === 'u')) move(-half)
160 else if (k === 'g' || k === 'home') move(-rowCount)
161 else if (k === 'G' || k === 'end') move(rowCount)
162 else if ((k === ']' || k === 'n') && props.fileIndex < props.files.length - 1) post({ type: 'file', index: props.fileIndex + 1 })
163 else if ((k === '[' || k === 'p') && props.fileIndex > 0) post({ type: 'file', index: props.fileIndex - 1 })
164 else if (k === 'f') surface.setState({ ...s, mode: 'files', fileCursor: props.fileIndex })
165 else if (k === 'c') startEdit('comment')
166 else if (k === 'C') startEdit('file')
167 else if (k === 's') startEdit('suggest')
168 else if (k === 'x') {
169 const row = props.file?.rows[s.cursor]
170 const path = props.file?.path ?? ''
171 const onRow = row === undefined ? [] : props.comments.filter(c => isOnRow(c, path, row))
172 const target = onRow[onRow.length - 1] ?? (s.cursor === 0 ? props.comments.find(c => c.side === 'file') : undefined)
173 if (target !== undefined) post({ type: 'delete', id: target.id })
174 } else if (k === 'r') post({ type: 'refresh' })
175 else if (k === 'S') post({ type: 'send', mode: 'submit' })
176 else if (k === 'P') post({ type: 'send', mode: 'fill' })
177 else if (k === 'q') post({ type: 'close' })
178 }
179
180 surface.onKey(key => {
181 if (s.edit !== null) onEditKey(s.edit, key)
182 else if (s.mode === 'files') onFilesKey(key)
183 else onDiffKey(key)
184 })
185
186 const header = (
187 <Text bold wrap="truncate-end">
188 {props.file === null
189 ? props.label
190 : `${props.file.path} (${props.file.status}${props.file.oldPath ? ` from ${props.file.oldPath}` : ''}) · file ${props.fileIndex + 1}/${props.files.length}`}
191 </Text>
192 )
193
194 const footer = (
195 <Box flexDirection="column">
196 {s.edit !== null ? (
197 <Box flexDirection="row">
198 <Box flexGrow={1} flexShrink={1}>
199 <Text wrap="truncate-start">
200 <Text color="claude">{s.edit.kind === 'suggest' ? 'suggest> ' : s.edit.kind === 'file' ? 'file comment> ' : 'comment> '}</Text>
201 {s.edit.text.slice(0, s.edit.pos)}
202 <Text inverse>{s.edit.text[s.edit.pos] ?? ' '}</Text>
203 {s.edit.text.slice(s.edit.pos + 1)}
204 </Text>
205 </Box>
206 <Button key="cancel" label="✕ cancel" onPress={cancelEdit} />
207 </Box>
208 ) : (
209 <Text dimColor wrap="truncate-end">
210 {props.total} comment(s) · {props.label} · click here to use keys
211 </Text>
212 )}
213 <Text dimColor wrap="truncate-end">
214 {s.edit !== null ? 'enter save · ctrl+g or ✕ cancel · ctrl+u clear' : s.mode === 'files' ? 'j/k move · enter open · f back' : HINTS}
215 </Text>
216 </Box>
217 )
218
219 if (props.error !== undefined) {
220 return (
221 <Box flexDirection="column">
222 <Text color="error">{props.error}</Text>
223 {footer}
224 </Box>
225 )
226 }
227
228 if (s.mode === 'files') {
229 const top = Math.max(0, Math.min(s.fileCursor - Math.floor(bodyRows / 2), props.files.length - bodyRows))
230
231 return (
232 <Box flexDirection="column" height={height}>
233 <Text bold>{props.files.length} files · {props.label}</Text>
234 <Box flexDirection="column" flexGrow={1}>
235 {props.files.slice(top, top + bodyRows).map((f, i) => (
236 <Text inverse={top + i === s.fileCursor} wrap="truncate-end">
237 {top + i === props.fileIndex ? '▸ ' : ' '}
238 {f.path} <Text dimColor>{f.status}</Text>
239 {f.comments > 0 ? <Text color="claude"> ● {f.comments}</Text> : ''}
240 </Text>
241 ))}
242 </Box>
243 {footer}
244 </Box>
245 )
246 }
247
248 if (props.file === null) {
249 return (
250 <Box flexDirection="column" height={height}>
251 <Text dimColor>No changes in {props.label}.</Text>
252 <Box flexGrow={1} />
253 {footer}
254 </Box>
255 )
256 }
257
258 const file = props.file
259 const width = String(file.rows.reduce((m, r) => Math.max(m, r.o ?? 0, r.n ?? 0), 0)).length
260 const at = lineOf(all, s.cursor)
261 const top = Math.max(0, at >= Math.min(s.top, at) + bodyRows ? at - bodyRows + 1 : Math.min(s.top, at))
262
263 return (
264 <Box flexDirection="column" height={height}>
265 {header}
266 <Box flexDirection="column" flexGrow={1}>
267 {all.slice(top, top + bodyRows).map(line => {
268 if ('comment' in line) {
269 const c = line.comment
270
271 return (
272 <Text color="claude" wrap="truncate-end">
273 {' '.repeat(width * 2 + 2)}
274 {c.side === 'file' ? '◆ file: ' : c.kind === 'suggest' ? '◆ suggest: ' : '◆ '}
275 {c.text}
276 </Text>
277 )
278 }
279 const row = file.rows[line.row]
280 if (row === undefined) return <Text> </Text>
281 const isCursor = line.row === s.cursor
282 const hasComment = props.comments.some(c => isOnRow(c, file.path, row))
283 const sign = row.k === 'add' ? '+' : row.k === 'del' ? '-' : ' '
284 const bg = row.k === 'add' ? 'diffAdded' : row.k === 'del' ? 'diffRemoved' : undefined
285
286 if (row.k === 'hunk' || row.k === 'note') {
287 return (
288 <Text color="suggestion" dimColor={!isCursor} inverse={isCursor} wrap="truncate-end">
289 {row.t}
290 </Text>
291 )
292 }
293
294 return (
295 <Text backgroundColor={bg} inverse={isCursor} wrap="truncate-end">
296 <Text dimColor>
297 {pad(row.o, width)} {pad(row.n, width)}
298 </Text>
299 {hasComment ? <Text color="claude">●</Text> : ' '}
300 {sign}
301 {row.t}
302 </Text>
303 )
304 })}
305 </Box>
306 {footer}
307 </Box>
308 )
309}
310
311export default Viewer
312types/index.d.ts 69 lines1export type RowKind = 'hunk' | 'ctx' | 'add' | 'del' | 'note'
2
3export type Row = { k: RowKind; t: string; o?: number; n?: number }
4
5export type FileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'binary'
6
7export type FileDiff = {
8 path: string
9 oldPath?: string
10 status: FileStatus
11 rows: Row[]
12}
13
14export type Anchor = {
15 path: string
16 side: 'new' | 'old' | 'file'
17 line?: number
18 code?: string
19}
20
21export type ReviewComment = Anchor & {
22 id: string
23 kind: 'comment' | 'suggest'
24 text: string
25}
26
27export type Review = {
28 root: string
29 target: string
30 label: string
31 files: FileDiff[]
32 error?: string
33}
34
35export type ViewerFile = { path: string; status: FileStatus; comments: number }
36
37export type ViewerProps = {
38 label: string
39 fileIndex: number
40 files: ViewerFile[]
41 file: FileDiff | null
42 comments: ReviewComment[]
43 total: number
44 cancelSeq: number
45 error?: string
46}
47
48export type ViewerMessage =
49 | { type: 'file'; index: number }
50 | { type: 'add'; comment: Omit<ReviewComment, 'id'> }
51 | { type: 'delete'; id: string }
52 | { type: 'refresh' }
53 | { type: 'send'; mode: 'submit' | 'fill' }
54 | { type: 'close' }
55 | { type: 'editing'; on: boolean }
56
57declare module 'claude-code' {
58 interface PluginState {
59 'local-review': {
60 review: Review | null
61 comments: ReviewComment[]
62 fileIndex: number
63 isOpen: boolean
64 isEditing: boolean
65 cancelSeq: number
66 }
67 }
68}
69