Early: crit inside Claude Code. Comment on the conversation, your diff or a file in a pane, send the review to Claude, and read Claude's replies in threads.

A crit review loop inside Claude Code, as a mod. Comment on lines of the conversation, the working-tree diff, or a file in a pane. Send the review to Claude in one go. Claude answers each comment in its thread through a tool.
Early version.
Needs Claude Code 2.1.287 or later (mods) and the crit CLI on your PATH.
/plugin marketplace add tomasz-tomczyk/crit
/plugin install crit-tui@crit
It sits next to the main crit plugin from the same marketplace; you can have both. To try it from a checkout instead:
claude --plugin-dir integrations/claude-code-tui
/crit-tui opens the pane (last source used, the conversation at first)/crit-tui chat, /crit-tui diff, /crit-tui file <path> pick the source/crit-tui send sends the review, /crit-tui close closes the paneThe pane is one Client region: it gets raw keys and mouse events. Click it once to give it the keys (Esc gives them back to the prompt).
| input | does |
|---|---|
| click a line | move the cursor there |
| drag | select rows |
| click a thread | pick it for r / x |
j k, ↑ ↓, wheel | move |
shift+↑ ↓, or v then move | select rows |
| space / pagedown, ctrl+u / pageup | page |
g G, home end | top, bottom |
n p, → ← | next, previous message or file |
l, tab | next thread |
c, enter | comment on the line or selection |
r | reply to the picked thread, or the one on this line |
x | resolve it |
s | send drafts and replies to Claude |
a | ask Claude to review the diff and leave comments |
1 2 3 | conversation, diff, file |
h | show or hide resolved threads |
f | reload the source |
While typing a comment: enter saves, shift+enter adds a line, ctrl+u clears. To cancel: enter or backspace on an empty box, or click cancel. ctrl+c and Esc never reach the pane: Claude Code takes them.
While the pane is closed, a band above the prompt shows drafts and new replies from Claude, with send and open buttons. The status line shows the same counts. A reply in the transcript that has comments gets a line under it with the count.
Three tools:
mcp__crit-tui__reply { id, body, resolve? }: answer a commentmcp__crit-tui__comment { file, line, end_line?, body }: leave a comment on codemcp__crit-tui__threads { include_resolved? }: list open threadssend submits one prompt with each new comment (where, quoted lines, body, id) and each new reply, and asks Claude to answer every item with reply.
Everything goes to crit through its CLI, so the crit browser UI and crit comments see the same review.
crit comment --json. A partial selection carries quote and quote_offset, as a web UI text selection does (an older crit ignores the two fields).claude-chat-<session id>. On the first chat comment the mod saves the conversation with crit plan --name <slug> --no-wait (one row per line), then comments with crit comment --plan <slug>. After each turn it saves again; crit adds a version only when the text changed. Sessions with no chat comments create nothing. Needs a crit with crit plan --no-wait; with an older crit, chat comments stay in the session.--author Claude.critBin), all comments stay in the session.hooks/register.tsx owns the data (documents, threads, crit sync) and draws the pane as one <Client module="./client.tsx">.hooks/client.tsx owns the cursor, selection, scrolling and the comment box, and posts each change (comment, reply, resolve, send, ...) to the hooks module through ui.message.setState made while the Client draws, so the Client keeps its state in a module variable and calls setState only from its key and mouse handlers.claude plugin validate integrations/claude-code-tui
claude plugin test integrations/claude-code-tui
-) diff lines anchor to the new-side line they sit at; crit has no old-side anchor in crit comment --json.crit comment --plan leaves anchor empty (crit bug), so a chat comment does not follow its text if earlier lines of the conversation change.git diff HEAD plus up to 20 untracked files.hooks/register.tsx 786 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ChatIndex, Doc, Source, Thread, UiState } from '../types'
5import {
6 EMPTY_UI,
7 TOOL_PREFIX,
8 chatDoc,
9 claudeIdsOf,
10 clientPropsOf,
11 critThreadsOf,
12 emptyDoc,
13 feedbackOf,
14 fileDoc,
15 newId,
16 parseDiff,
17 pendingOf,
18 quoteOffsetOf,
19 chatLinePrefix,
20 chatMarkdownOf,
21 chatPlanSlug,
22 chatThreadsFromPlan,
23 sectionBefore,
24 selectionOf,
25 whereOf,
26} from './model'
27
28type $ = EngineInterface
29
30export const PANE = 'crit'
31const MAX_UNTRACKED = 20
32
33const threadsA = atom({ plugin: 'crit-tui', key: 'threads' } as const, [] as Thread[])
34const critThreadsA = atom({ plugin: 'crit-tui', key: 'critThreads' } as const, [] as Thread[])
35const sentA = atom({ plugin: 'crit-tui', key: 'sent' } as const, [] as string[])
36const seenA = atom({ plugin: 'crit-tui', key: 'seen' } as const, [] as string[])
37const uiA = atom({ plugin: 'crit-tui', key: 'ui' } as const, EMPTY_UI)
38const chatIndexA = atom({ plugin: 'crit-tui', key: 'chatIndex' } as const, [] as ChatIndex)
39const isOpenA = atom({ plugin: 'crit-tui', key: 'isOpen' } as const, false)
40const docsA = atom({ plugin: 'crit-tui', key: 'docs' } as const, {
41 chat: emptyDoc('chat', 'Conversation', 'Loading…'),
42 diff: emptyDoc('diff', 'Diff', 'Loading…'),
43 file: emptyDoc('file', 'File', 'Open a file with /crit-tui file <path>.'),
44})
45
46// Values a reload recomputes; the review itself lives in $.state.
47let critBin = 'crit'
48let reviewFile: string | null = null
49let hasCrit = true
50let diffTimer: Timer | null = null
51// Chat comments live in a crit plan when the crit CLI has `plan --no-wait`.
52let hasChatPlans: boolean | null = null
53let chatSlug: string | null = null
54let chatPlanFile: string | null = null
55let savedChat = ''
56
57// ── Loading documents ─────────────────────────────────────────────────
58
59async function setDoc($: $, source: Source, doc: Doc) {
60 await update($, docsA, docs => ({ ...docs, [source]: doc }))
61}
62
63async function refreshChat($: $) {
64 const messages = await $.session.messages().catch(() => [])
65 const { doc, index } = chatDoc(messages)
66 await setDoc($, 'chat', doc)
67 await update($, chatIndexA, () => index)
68 // Once the chat has comments in crit, each turn is a new plan version.
69 if (savedChat !== '') {
70 await saveChatPlan($)
71 }
72}
73
74async function git($: $, argv: string[]) {
75 return $.process.run(['git', ...argv], { timeoutMs: 15000 })
76}
77
78async function refreshDiff($: $) {
79 const inside = await git($, ['rev-parse', '--is-inside-work-tree']).catch(() => null)
80 if (!inside || inside.exitCode !== 0) {
81 await setDoc($, 'diff', emptyDoc('diff', 'Diff', 'Not a git repository.'))
82 return
83 }
84
85 let tracked = await git($, ['diff', 'HEAD', '--no-color', '--no-ext-diff', '-U3'])
86 if (tracked.exitCode !== 0) {
87 tracked = await git($, ['diff', '--no-color', '--no-ext-diff', '-U3'])
88 }
89
90 const others = await git($, ['ls-files', '--others', '--exclude-standard'])
91 const untracked = others.stdout.split('\n').filter(Boolean).slice(0, MAX_UNTRACKED)
92 const added = await Promise.all(
93 untracked.map(path =>
94 git($, ['diff', '--no-color', '--no-index', '--', '/dev/null', path])
95 .then(run => run.stdout)
96 .catch(() => ''),
97 ),
98 )
99
100 const rows = parseDiff([tracked.stdout, ...added].join('\n'))
101 const files = rows.filter(row => row.kind === 'title').length
102 await setDoc(
103 $,
104 'diff',
105 rows.length === 0
106 ? emptyDoc('diff', 'Diff', 'No uncommitted changes.')
107 : { source: 'diff', title: `${files} file${files === 1 ? '' : 's'} changed`, rows },
108 )
109}
110
111async function refreshFile($: $, path: string) {
112 try {
113 await setDoc($, 'file', fileDoc(path, await $.fs.read(path)))
114 } catch (error) {
115 await setDoc($, 'file', emptyDoc('file', path, `Cannot read ${path}: ${String(error)}`))
116 }
117}
118
119async function refreshSource($: $, source: Source) {
120 if (source === 'chat') {
121 await refreshChat($)
122 } else if (source === 'diff') {
123 await refreshDiff($)
124 } else {
125 const { filePath } = await readUi($)
126 if (filePath) {
127 await refreshFile($, filePath)
128 }
129 }
130}
131
132// ── crit's review file ────────────────────────────────────────────────
133
134async function crit($: $, argv: string[], stdin?: string) {
135 const run = await $.process.run([critBin, ...argv], { timeoutMs: 15000, ...(stdin ? { stdin } : {}) })
136 if (run.exitCode !== 0) {
137 throw new Error((run.stderr || run.stdout).trim() || `crit ${argv[0]} failed`)
138 }
139 return run.stdout
140}
141
142async function refreshCrit($: $) {
143 if (!hasCrit) {
144 return
145 }
146 try {
147 if (!reviewFile) {
148 const status = JSON.parse(await crit($, ['status', '--json'])) as { review_file?: string }
149 reviewFile = status.review_file ?? null
150 }
151 const code = reviewFile ? critThreadsOf(await $.fs.read(reviewFile).catch(() => '')) : []
152 const chat = await readChatPlanThreads($)
153 await update($, critThreadsA, () => [...code, ...chat])
154 } catch {
155 hasCrit = false
156 }
157}
158
159// ── The conversation as a crit plan ─────────────────────────────────────
160
161async function chatPlanReady($: $): Promise<boolean> {
162 if (!hasCrit) {
163 return false
164 }
165 if (hasChatPlans === null) {
166 const help = await $.process.run([critBin, 'plan', '--help'], { timeoutMs: 15000 }).catch(() => null)
167 hasChatPlans = help !== null && `${help.stdout}${help.stderr}`.includes('--no-wait')
168 }
169 if (hasChatPlans && !chatSlug) {
170 chatSlug = chatPlanSlug(await $.session.id())
171 const home = await $.env.get('HOME')
172 chatPlanFile = home ? `${home}/.crit/plans/${chatSlug}/.crit/review.json` : null
173 }
174 return hasChatPlans
175}
176
177/** Saves the conversation as the plan's next version (crit adds none when unchanged). */
178async function saveChatPlan($: $) {
179 if (!(await chatPlanReady($)) || !chatSlug) {
180 return
181 }
182 const markdown = chatMarkdownOf((await read($, docsA)).chat)
183 if (markdown === savedChat) {
184 return
185 }
186 await crit($, ['plan', '--name', chatSlug, '--no-wait', '--quiet'], markdown)
187 savedChat = markdown
188}
189
190async function readChatPlanThreads($: $): Promise<Thread[]> {
191 if (!(await chatPlanReady($)) || !chatPlanFile) {
192 return []
193 }
194 const json = await $.fs.read(chatPlanFile).catch(() => '')
195 if (json === '') {
196 return []
197 }
198 // A plan from earlier in this session: keep saving it each turn.
199 if (savedChat === '') {
200 savedChat = 'saved'
201 }
202 return chatThreadsFromPlan(critThreadsOf(json), (await read($, docsA)).chat.rows)
203}
204
205/** `crit comment` arguments that point at the chat plan for a chat thread. */
206function planArgs(thread: Pick<Thread, 'kind'>): string[] {
207 return thread.kind === 'chat' && chatSlug ? ['--plan', chatSlug] : []
208}
209
210// ── Review state ──────────────────────────────────────────────────────
211
212async function allThreads($: $) {
213 return [...(await read($, threadsA)), ...(await read($, critThreadsA))]
214}
215
216function threadsFor(source: Source, local: readonly Thread[], fromCrit: readonly Thread[]) {
217 const kind = source === 'chat' ? 'chat' : 'code'
218 return [...fromCrit, ...local].filter(thread => thread.kind === kind)
219}
220
221async function sync($: $) {
222 const threads = await allThreads($)
223 const pending = pendingOf(threads, await read($, sentA), await read($, seenA))
224 const drafts = pending.comments.length + pending.replies.length
225 const parts = [drafts ? `${drafts} to send` : '', pending.unread ? `${pending.unread} new from Claude` : '']
226 const text = parts.filter(Boolean).join(' · ')
227 $.ui.status(text ? `crit: ${text}` : undefined)
228}
229
230async function markSeen($: $) {
231 const ids = claudeIdsOf(await allThreads($))
232 await update($, seenA, seen => [...new Set([...seen, ...ids])])
233}
234
235async function findThread($: $, id: string): Promise<Thread | undefined> {
236 const threads = await allThreads($)
237 return threads.find(thread => thread.id === id) ?? threads.find(thread => id.length >= 4 && thread.id.startsWith(id))
238}
239
240async function addReply($: $, thread: Thread, body: string, who: 'you' | 'claude', resolve: boolean) {
241 if ((await read($, critThreadsA)).some(one => one.id === thread.id)) {
242 const argv = ['comment', ...planArgs(thread), '--reply-to', thread.id, ...(who === 'claude' ? ['--author', 'Claude'] : []), ...(resolve ? ['--resolve'] : []), body]
243 await crit($, argv)
244 await refreshCrit($)
245 return
246 }
247 await update($, threadsA, list =>
248 list.map(one =>
249 one.id === thread.id
250 ? {
251 ...one,
252 resolved: resolve ? true : one.resolved,
253 replies: [
254 ...one.replies,
255 { id: newId(), author: who, body, at: new Date().toISOString(), fromClaude: who === 'claude' },
256 ],
257 }
258 : one,
259 ),
260 )
261}
262
263// ── Pane actions, mostly asked for by the Client ──────────────────────
264
265/** The pane's settings, filled in from the defaults (an older build stored fewer fields). */
266async function readUi($: $): Promise<UiState> {
267 return { ...EMPTY_UI, ...(await read($, uiA)) }
268}
269
270async function setUi($: $, fn: (ui: UiState) => UiState) {
271 await update($, uiA, now => fn({ ...EMPTY_UI, ...now }))
272}
273
274async function openSource($: $, source: Source) {
275 const ui = await readUi($)
276 if (source === 'file' && !ui.filePath) {
277 $.ui.toast('Open a file first: /crit-tui file <path>')
278 return
279 }
280 await refreshSource($, source)
281 const doc = (await read($, docsA))[source]
282 const startAt = source === 'chat' ? sectionBefore(doc.rows, doc.rows.length) : 0
283 await setUi($, now => ({ ...now, source, startAt, center: startAt, docRev: now.docRev + 1 }))
284}
285
286async function openPane($: $, source?: Source) {
287 const ui = await readUi($)
288 if (source || ui.docRev === 0) {
289 await openSource($, source ?? ui.source)
290 } else {
291 await refreshSource($, ui.source)
292 }
293 await refreshCrit($)
294 await update($, isOpenA, () => true)
295 // Inline above the prompt a pane opens a third of the screen tall; ask for more.
296 await $.ui.open({ id: PANE, title: 'crit', focus: true, rows: 28 })
297 await markSeen($)
298 await sync($)
299}
300
301async function addComment($: $, start: number, end: number, body: string, cols?: { startCol: number | null; endCol: number | null }) {
302 const ui = await readUi($)
303 const picked = selectionOf((await read($, docsA))[ui.source], start, end, cols)
304 if (typeof picked === 'string') {
305 $.ui.toast(picked)
306 return
307 }
308 const isChat = picked.path.startsWith('chat:')
309 const doc = (await read($, docsA))[ui.source]
310 if (isChat && (await chatPlanReady($)) && chatSlug) {
311 // Chat comments go to the plan: its line numbers are row indices plus one.
312 await saveChatPlan($)
313 const lo = Math.min(start, end)
314 const hi = Math.max(start, end)
315 const lines = doc.rows.slice(lo, hi + 1).map(row => `${chatLinePrefix(row)}${row.text}`)
316 const firstPrefix = doc.rows[lo] ? chatLinePrefix(doc.rows[lo]) : ''
317 const offset = picked.isExact && cols?.startCol != null ? quoteOffsetOf(lines, cols.startCol + firstPrefix.length, picked.quote) : undefined
318 const entry = {
319 file: `${chatSlug}.md`,
320 line: lo === hi ? lo + 1 : `${lo + 1}-${hi + 1}`,
321 body,
322 ...(picked.isExact && picked.quote ? { quote: picked.quote } : {}),
323 ...(offset !== undefined ? { quote_offset: offset } : {}),
324 }
325 await crit($, ['comment', '--plan', chatSlug, '--json'], JSON.stringify([entry]))
326 await refreshCrit($)
327 return
328 }
329 if (!isChat && hasCrit) {
330 const line = picked.start === picked.end ? picked.start : `${picked.start}-${picked.end}`
331 // An exact selection carries crit's quote fields, as a web UI selection does.
332 const lines = doc.rows
333 .filter(row => row.path === picked.path && row.line !== undefined && row.line >= picked.start && row.line <= picked.end && row.sign !== '-')
334 .map(row => row.text)
335 const offset = picked.isExact && cols?.startCol != null ? quoteOffsetOf(lines, cols.startCol, picked.quote) : undefined
336 const entry = {
337 file: picked.path,
338 line,
339 body,
340 ...(picked.isExact && picked.quote ? { quote: picked.quote } : {}),
341 ...(offset !== undefined ? { quote_offset: offset } : {}),
342 }
343 await crit($, ['comment', '--json'], JSON.stringify([entry]))
344 await refreshCrit($)
345 return
346 }
347 const { isExact: _isExact, ...anchor } = picked
348 const thread: Thread = {
349 id: newId(),
350 kind: isChat ? 'chat' : 'code',
351 ...anchor,
352 body,
353 author: 'you',
354 fromClaude: false,
355 at: new Date().toISOString(),
356 resolved: false,
357 replies: [],
358 }
359 await update($, threadsA, list => [...list, thread])
360}
361
362async function replyAsYou($: $, id: string, body: string) {
363 const thread = await findThread($, id)
364 if (thread) {
365 await addReply($, thread, body, 'you', false)
366 }
367}
368
369async function toggleResolve($: $, id: string) {
370 const target = await findThread($, id)
371 if (!target) {
372 return
373 }
374 const isLocal = (await read($, threadsA)).some(one => one.id === target.id)
375 if (isLocal) {
376 await update($, threadsA, list => list.map(one => (one.id === target.id ? { ...one, resolved: !one.resolved } : one)))
377 } else if (!target.resolved) {
378 await crit($, ['comment', ...planArgs(target), '--reply-to', target.id, '--resolve', 'Resolved.'])
379 await refreshCrit($)
380 } else {
381 $.ui.toast('Reopen this one in the crit browser view.')
382 }
383}
384
385type ClientMessage = {
386 type?: string
387 center?: number
388 start?: number
389 end?: number
390 startCol?: number | null
391 endCol?: number | null
392 body?: string
393 id?: string
394 source?: string
395}
396
397const isSource = (value: unknown): value is Source => value === 'chat' || value === 'diff' || value === 'file'
398
399async function onClientMessage($: $, data: unknown) {
400 const message = (data ?? {}) as ClientMessage
401 try {
402 switch (message.type) {
403 case 'window':
404 if (typeof message.center === 'number') {
405 const center = message.center
406 await setUi($, now => ({ ...now, center }))
407 }
408 return
409 case 'comment':
410 if (typeof message.start === 'number' && typeof message.end === 'number' && message.body) {
411 const cols = typeof message.startCol === 'number' && typeof message.endCol === 'number' ? { startCol: message.startCol, endCol: message.endCol } : undefined
412 await addComment($, message.start, message.end, message.body, cols)
413 }
414 break
415 case 'reply':
416 if (message.id && message.body) {
417 await replyAsYou($, message.id, message.body)
418 }
419 break
420 case 'resolve':
421 if (message.id) {
422 await toggleResolve($, message.id)
423 }
424 break
425 case 'send':
426 await send($)
427 break
428 case 'ask':
429 await askForReview($)
430 break
431 case 'seen':
432 break
433 case 'toggleResolved':
434 await setUi($, now => ({ ...now, resolvedView: now.resolvedView === 'line' ? 'hidden' : now.resolvedView === 'hidden' ? 'full' : 'line' }))
435 break
436 case 'refresh':
437 await refreshSource($, (await readUi($)).source)
438 await refreshCrit($)
439 break
440 case 'source':
441 if (isSource(message.source)) {
442 await openSource($, message.source)
443 }
444 break
445 }
446 } catch (error) {
447 $.ui.toast(`crit: ${String(error).slice(0, 160)}`)
448 }
449 await markSeen($)
450 await sync($)
451}
452
453async function send($: $) {
454 const threads = await allThreads($)
455 const pending = pendingOf(threads, await read($, sentA), await read($, seenA))
456 if (pending.comments.length + pending.replies.length === 0) {
457 $.ui.toast('Nothing new to send.')
458 return
459 }
460 const text = feedbackOf(pending, await read($, chatIndexA))
461 const ids = [...pending.comments.map(one => one.id), ...pending.replies.map(one => one.reply.id)]
462 await update($, sentA, sent => [...new Set([...sent, ...ids])])
463 await $.prompt.submit({ text, asUser: true })
464 $.ui.toast(`Sent ${ids.length} item${ids.length === 1 ? '' : 's'} to Claude.`)
465 await sync($)
466}
467
468async function askForReview($: $) {
469 await $.prompt.submit({
470 text: [
471 'Review the uncommitted changes (git diff HEAD, plus new files).',
472 `Leave each finding as an inline comment with the ${TOOL_PREFIX}comment tool: file, line (new-side line number), optional end_line, and a short body.`,
473 'Do not change any code yet. I will reply to your comments in the crit pane.',
474 ].join(' '),
475 asUser: true,
476 })
477 $.ui.toast('Asked Claude to review the diff.')
478}
479
480export const register: Register = (on, options) => {
481 critBin = typeof options.critBin === 'string' && options.critBin !== '' ? options.critBin : 'crit'
482 reviewFile = null
483 hasCrit = true
484
485 // ── Session ───────────────────────────────────────────────────────────
486
487 on('session.start', async ($, e, next) => {
488 const started = await next(e)
489
490 await $.command.register({
491 name: 'crit-tui',
492 description: 'crit: review the chat, your diff or a file, in a pane',
493 argumentHint: '[chat|diff|file <path>|send|close]',
494 immediate: true,
495 })
496
497 await $.tool.register({
498 name: 'reply',
499 description:
500 'Reply to a crit review comment the user left (on the conversation, the diff or a file). Pass the comment id from the review and a short reply. The user resolves threads, not you.',
501 inputSchema: {
502 type: 'object',
503 properties: {
504 id: { type: 'string', description: 'The comment id, as given in the review.' },
505 body: { type: 'string', description: 'Your reply.' },
506 },
507 required: ['id', 'body'],
508 },
509 })
510
511 await $.tool.register({
512 name: 'comment',
513 description:
514 'Leave an inline crit review comment on a line of a file in the repository (new-side line numbers for diffs). The user reads it in the crit pane and can reply.',
515 inputSchema: {
516 type: 'object',
517 properties: {
518 file: { type: 'string', description: 'Path relative to the repository root.' },
519 line: { type: 'integer', minimum: 1 },
520 end_line: { type: 'integer', minimum: 1 },
521 body: { type: 'string' },
522 },
523 required: ['file', 'line', 'body'],
524 },
525 })
526
527 await $.tool.register({
528 name: 'threads',
529 description: 'List open crit review threads (comments and replies) with their ids.',
530 inputSchema: { type: 'object', properties: { include_resolved: { type: 'boolean' } } },
531 })
532
533 await refreshCrit($)
534 await sync($)
535
536 return started
537 })
538
539 on('command.run', { command: 'crit-tui' }, async ($, e) => {
540 const [word = '', ...rest] = e.args.trim().split(/\s+/)
541 const arg = rest.join(' ')
542
543 switch (word) {
544 case 'send':
545 await send($)
546 return { text: 'crit: review sent.' }
547 case 'close':
548 await $.ui.close({ id: PANE })
549 await update($, isOpenA, () => false)
550 return { text: 'crit pane closed.' }
551 case 'file': {
552 if (!arg) {
553 return { text: 'Usage: /crit-tui file <path>' }
554 }
555 await setUi($, now => ({ ...now, filePath: arg }))
556 await openPane($, 'file')
557 return { text: `crit: reviewing ${arg}.` }
558 }
559 case 'chat':
560 case 'diff':
561 await openPane($, word)
562 return { text: `crit: reviewing the ${word === 'chat' ? 'conversation' : 'diff'}.` }
563 case '':
564 await openPane($)
565 return { text: 'crit pane open. Click it to give it the keys.' }
566 default:
567 return { text: `Unknown /crit-tui argument "${word}". Try chat, diff, file <path>, send or close.` }
568 }
569 })
570
571 on('ui.close', { id: PANE }, async ($, e, next) => {
572 await update($, isOpenA, () => false)
573 return next(e)
574 })
575
576 // ── Tools Claude calls ────────────────────────────────────────────────
577
578 on('tool.call', { tool: 'mcp__crit-tui__reply' }, async ($, e) => {
579 const input = e as unknown as { id?: string; body?: string }
580 const thread = input.id ? await findThread($, input.id) : undefined
581 if (!thread || !input.body) {
582 return { deny: `No crit thread with id ${input.id ?? '(none)'}. Call ${TOOL_PREFIX}threads to list them.` }
583 }
584 try {
585 await addReply($, thread, input.body, 'claude', false)
586 } catch (error) {
587 return { deny: `crit could not save the reply: ${String(error)}` }
588 }
589 $.ui.toast(`Claude replied on ${whereOf(thread, await read($, chatIndexA))}`)
590 await sync($)
591 return { result: 'Reply saved. The user sees it in the crit pane.' }
592 })
593
594 on('tool.call', { tool: 'mcp__crit-tui__comment' }, async ($, e) => {
595 const input = e as unknown as { file?: string; line?: number; end_line?: number; body?: string }
596 if (!input.file || !input.line || !input.body) {
597 return { deny: 'file, line and body are required.' }
598 }
599 const end = input.end_line && input.end_line > input.line ? input.end_line : input.line
600 try {
601 if (hasCrit) {
602 await crit($, ['comment', '--author', 'Claude', `${input.file}:${input.line}${end !== input.line ? `-${end}` : ''}`, input.body])
603 await refreshCrit($)
604 } else {
605 const thread: Thread = {
606 id: newId(),
607 kind: 'code',
608 path: input.file,
609 start: input.line,
610 end,
611 quote: '',
612 body: input.body,
613 author: 'claude',
614 fromClaude: true,
615 at: new Date().toISOString(),
616 resolved: false,
617 replies: [],
618 }
619 await update($, threadsA, list => [...list, thread])
620 }
621 } catch (error) {
622 return { deny: `crit could not save the comment: ${String(error)}` }
623 }
624 await sync($)
625 return { result: `Comment left on ${input.file}:${input.line}.` }
626 })
627
628 on('tool.call', { tool: 'mcp__crit-tui__threads' }, async ($, e) => {
629 const input = e as unknown as { include_resolved?: boolean }
630 const index = await read($, chatIndexA)
631 const threads = (await allThreads($)).filter(thread => input.include_resolved || !thread.resolved)
632 if (threads.length === 0) {
633 return { result: 'No open crit threads.' }
634 }
635 const text = threads
636 .map(thread =>
637 [
638 `[id ${thread.id}] ${whereOf(thread, index)}${thread.resolved ? ' (resolved)' : ''}`,
639 ` ${thread.fromClaude ? 'claude' : 'user'}: ${thread.body}`,
640 ...thread.replies.map(reply => ` ${reply.fromClaude ? 'claude' : 'user'}: ${reply.body}`),
641 ].join('\n'),
642 )
643 .join('\n\n')
644 return { result: text }
645 })
646
647 // ── Keeping documents fresh ───────────────────────────────────────────
648
649 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
650 const ran = await next(e)
651 if ((await read($, isOpenA)) && (await readUi($)).source === 'diff') {
652 diffTimer?.cancel()
653 diffTimer = $.clock.after(500, () => {
654 void refreshDiff($)
655 })
656 }
657 return ran
658 })
659
660 on('turn.complete', async ($, e, next) => {
661 const done = await next(e)
662 await refreshChat($)
663 await refreshCrit($)
664 if ((await read($, isOpenA)) && (await readUi($)).source === 'diff') {
665 await refreshDiff($)
666 }
667 await sync($)
668 return done
669 })
670
671 // ── The Client's messages, and the wheel over the pane ────────────────
672
673 on('ui.message', { requestId: PANE }, async ($, e) => {
674 await onClientMessage($, e.data)
675 return {}
676 })
677
678 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
679 if (e.origin.kind !== 'person') {
680 return next(e)
681 }
682 const by = e.by
683 await setUi($, now => ({ ...now, wheel: now.wheel + 1, wheelBy: by }))
684 return {}
685 })
686
687 // ── The pane: one Client that owns keys, mouse and drawing ────────────
688
689 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
690 if (e.surface === 'mobile' || e.surface === 'vscode') {
691 const { Text } = $.ui.resolve(e)
692 return <Text dimColor>crit-tui needs the terminal or the desktop app.</Text>
693 }
694
695 const { Client } = $.ui.resolve(e)
696 const ui = await readUi($)
697 const local = await read($, threadsA)
698 const fromCrit = await read($, critThreadsA)
699 const sent = await read($, sentA)
700 const seen = await read($, seenA)
701 const pending = pendingOf([...local, ...fromCrit], sent, seen)
702 const drafts = pending.comments.length + pending.replies.length
703 const status = hasCrit ? '' : 'no crit review file: code comments stay here'
704
705 const props = clientPropsOf({
706 doc: (await read($, docsA))[ui.source],
707 threads: threadsFor(ui.source, local, fromCrit),
708 ui,
709 sent,
710 seen,
711 status,
712 toSend: drafts,
713 unread: pending.unread,
714 })
715
716 return (
717 <Client
718 key="review"
719 module="./client.tsx"
720 props={props}
721 width={Math.max(30, e.props.bodyColumns)}
722 height={Math.max(6, e.props.scroll.bodyRows)}
723 />
724 )
725 })
726
727 // ── The band above the prompt while the pane is closed ────────────────
728
729 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
730 if (e.props.hasSurvey || (await read($, isOpenA))) {
731 return next(e)
732 }
733 const pending = pendingOf(await allThreads($), await read($, sentA), await read($, seenA))
734 const drafts = pending.comments.length + pending.replies.length
735 if (drafts === 0 && pending.unread === 0) {
736 return next(e)
737 }
738 const { Box, Text, Button } = $.ui.resolve(e)
739 const parts = [
740 drafts ? `${drafts} comment${drafts === 1 ? '' : 's'} to send` : '',
741 pending.unread ? `${pending.unread} new from Claude` : '',
742 ]
743 return (
744 <Box flexDirection="row" gap={2}>
745 <Text color="magenta">crit</Text>
746 <Text dimColor>{parts.filter(Boolean).join(' · ')}</Text>
747 {drafts > 0 && <Button key="band-send" hotkey="s" label="send" variant="primary" onPress={() => void send($)} />}
748 <Button key="band-open" hotkey="o" label="open" onPress={() => void openPane($)} />
749 </Box>
750 )
751 })
752
753 // ── A note under replies that have comments ───────────────────────────
754
755 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
756 const text = e.props.text.trim()
757 if (text === '') {
758 return next(e)
759 }
760 const index = await read($, chatIndexA)
761 const probe = text.slice(0, 200)
762 const entry = index.find(one => one.role === 'assistant' && one.text.includes(probe))
763 if (!entry) {
764 return next(e)
765 }
766 const all = [...(await read($, threadsA)), ...(await read($, critThreadsA))]
767 const mine = all.filter(thread => thread.path === `chat:${entry.index}`)
768 if (mine.length === 0) {
769 return next(e)
770 }
771 const open = mine.filter(thread => !thread.resolved).length
772 const replies = mine.reduce((sum, thread) => sum + thread.replies.length, 0)
773 const drawn = await next(e)
774 const { Box, Text } = $.ui.resolve(e)
775 return (
776 <Box flexDirection="column">
777 {drawn}
778 <Text color="magenta" dimColor>
779 {' '}💬 {mine.length} crit comment{mine.length === 1 ? '' : 's'} · {open} open
780 {replies ? ` · ${replies} repl${replies === 1 ? 'y' : 'ies'}` : ''} · /crit-tui to open
781 </Text>
782 </Box>
783 )
784 }).catch(($, e, next) => next(e))
785}
786hooks/model.ts 626 lines1import type { SessionMessage } from 'claude-code'
2
3import type { ChatIndex, ClientRow, ClientThread, Doc, Reply, ReviewProps, Row, Source, Thread, UiState } from '../types'
4
5export const TOOL_PREFIX = 'mcp__crit-tui__'
6const MAX_ROWS = 6000
7const TOOL_OUTPUT_LINES = 6
8
9export const EMPTY_UI: UiState = {
10 source: 'chat',
11 startAt: 0,
12 center: 0,
13 resolvedView: 'line',
14 filePath: null,
15 docRev: 0,
16 wheel: 0,
17 wheelBy: 0,
18}
19
20export const emptyDoc = (source: Source, title: string, error?: string): Doc => ({
21 source,
22 title,
23 rows: [],
24 ...(error ? { error } : {}),
25})
26
27const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
28
29const stripAnsi = (text: string) => text.replace(ANSI, '').replace(/\t/g, ' ')
30
31const isClaude = (author: string) => /claude/i.test(author)
32
33// ── Documents ───────────────────────────────────────────────────────────
34
35/**
36 * Turns `git diff` output into rows. `+` and context lines anchor to their
37 * new line number; a `-` line anchors to the new-side position it sits at.
38 */
39export function parseDiff(text: string): Row[] {
40 const rows: Row[] = []
41 let path = ''
42 let oldLine = 0
43 let newLine = 0
44
45 for (const raw of text.split('\n')) {
46 if (rows.length >= MAX_ROWS) {
47 rows.push({ kind: 'note', text: '… diff cut here' })
48 break
49 }
50
51 if (raw.startsWith('diff --git ')) {
52 const match = / b\/(.+)$/.exec(raw)
53 path = match?.[1] ?? raw.slice(11)
54 rows.push({ kind: 'title', text: path, path })
55 continue
56 }
57
58 if (raw.startsWith('+++ ')) {
59 const next = raw.slice(4).replace(/^b\//, '')
60 if (next !== '/dev/null' && next !== path) {
61 path = next
62 const title = [...rows].reverse().find(row => row.kind === 'title')
63 if (title) {
64 title.text = path
65 title.path = path
66 }
67 }
68 continue
69 }
70
71 if (/^(--- |index |new file mode|deleted file mode|similarity |rename |old mode|new mode)/.test(raw)) {
72 continue
73 }
74
75 const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(raw)
76 if (hunk) {
77 oldLine = Number(hunk[1])
78 newLine = Number(hunk[2])
79 rows.push({ kind: 'hunk', text: `@@ -${hunk[1]} +${hunk[2]} @@${hunk[3]}` })
80 continue
81 }
82
83 if (raw.startsWith('Binary files')) {
84 rows.push({ kind: 'note', text: raw })
85 continue
86 }
87
88 if (raw.startsWith('\\') || path === '') {
89 continue
90 }
91
92 const sign = raw[0]
93 const body = stripAnsi(raw.slice(1))
94
95 if (sign === '+') {
96 rows.push({ kind: 'line', sign: '+', text: body, path, line: newLine, no: String(newLine) })
97 newLine += 1
98 } else if (sign === '-') {
99 rows.push({ kind: 'line', sign: '-', text: body, path, line: Math.max(1, newLine), no: '' })
100 oldLine += 1
101 } else if (sign === ' ') {
102 rows.push({ kind: 'line', sign: ' ', text: body, path, line: newLine, no: String(newLine) })
103 newLine += 1
104 oldLine += 1
105 }
106 }
107
108 return rows
109}
110
111/** One line telling what a tool call did. */
112const REVIEW_HEAD = 'Crit review:'
113
114/** This mod's own tool calls, and the lookup that loads them, are left out of the chat. */
115function isCritPlumbing(tool: string, input: Record<string, unknown>): boolean {
116 if (tool.startsWith(TOOL_PREFIX)) return true
117 return tool === 'ToolSearch' && typeof input.query === 'string' && input.query.includes('crit-tui')
118}
119
120function toolSummary(tool: string, input: Record<string, unknown>): string {
121 const pick = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
122 const arg =
123 pick('command') ||
124 pick('file_path') ||
125 pick('pattern') ||
126 pick('path') ||
127 pick('url') ||
128 pick('description') ||
129 JSON.stringify(input)
130
131 const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(':') : tool
132
133 return `${name}(${arg.replace(/\s+/g, ' ').slice(0, 120)})`
134}
135
136/**
137 * The conversation as rows: a title per message, its text lines anchored to
138 * `chat:<index>`, and each tool call with the head of its output, anchored
139 * to `chat:<index>/<tool_use_id>`.
140 */
141export function chatDoc(messages: readonly SessionMessage[]): { doc: Doc; index: ChatIndex } {
142 const rows: Row[] = []
143 const index: ChatIndex = []
144
145 messages.forEach((message, at) => {
146 const text = message.text.trim()
147 const tools = message.toolUses.filter(use => !isCritPlumbing(use.tool, use.input))
148 const hasTools = tools.length > 0
149
150 // The review this mod sent shows as one line, not the whole prompt.
151 if (message.role === 'user' && text.startsWith(REVIEW_HEAD)) {
152 rows.push({ kind: 'note', text: `↳ you sent a crit review: ${text.split('\n')[0]?.slice(REVIEW_HEAD.length).trim()}`, role: 'user' })
153 return
154 }
155
156 if (text === '' && !hasTools) {
157 return
158 }
159
160 const path = `chat:${at}`
161
162 if (text !== '') {
163 index.push({ index: at, role: message.role, text })
164 rows.push({
165 kind: 'title',
166 text: message.role === 'user' ? `you · #${at}` : `claude · #${at}`,
167 role: message.role,
168 path,
169 })
170 text.split('\n').forEach((line, n) => {
171 rows.push({ kind: 'line', text: stripAnsi(line), role: message.role, path, line: n + 1, no: String(n + 1) })
172 })
173 }
174
175 for (const use of tools) {
176 const toolPath = `${path}/${use.tool_use_id}`
177 rows.push({ kind: 'hunk', text: `⏺ ${toolSummary(use.tool, use.input)}`, role: 'tool', path: toolPath })
178
179 const output = stripAnsi(use.text ?? '').split('\n').filter(line => line.trim() !== '')
180 output.slice(0, TOOL_OUTPUT_LINES).forEach((line, n) => {
181 rows.push({ kind: 'line', text: line, role: 'tool', path: toolPath, line: n + 1, no: use.isError ? '!' : '' })
182 })
183 if (output.length > TOOL_OUTPUT_LINES) {
184 rows.push({ kind: 'note', text: ` … ${output.length - TOOL_OUTPUT_LINES} more lines`, role: 'tool' })
185 }
186 }
187 })
188
189 const doc: Doc =
190 rows.length === 0
191 ? emptyDoc('chat', 'Conversation', 'Nothing said yet.')
192 : { source: 'chat', title: 'Conversation', rows }
193
194 return { doc, index }
195}
196
197export function fileDoc(path: string, content: string): Doc {
198 const lines = content.split('\n')
199 if (lines.at(-1) === '') {
200 lines.pop()
201 }
202
203 const rows: Row[] = [{ kind: 'title', text: path, path }]
204 lines.slice(0, MAX_ROWS).forEach((line, n) => {
205 rows.push({ kind: 'line', text: stripAnsi(line), path, line: n + 1, no: String(n + 1) })
206 })
207
208 return { source: 'file', title: path, rows }
209}
210
211// ── crit's review.json ──────────────────────────────────────────────────
212
213type CritReply = { id?: string; body?: string; author?: string; created_at?: string }
214type CritComment = CritReply & {
215 start_line?: number
216 end_line?: number
217 quote?: string
218 anchor?: string
219 resolved?: boolean
220 replies?: CritReply[]
221}
222
223/** Reads crit's review.json into code threads. */
224export function critThreadsOf(json: string): Thread[] {
225 let parsed: unknown
226 try {
227 parsed = JSON.parse(json)
228 } catch {
229 return []
230 }
231
232 const files = (parsed as { files?: Record<string, { comments?: CritComment[] }> })?.files ?? {}
233 const threads: Thread[] = []
234
235 for (const [path, file] of Object.entries(files)) {
236 for (const comment of file?.comments ?? []) {
237 if (!comment.id) {
238 continue
239 }
240 const author = comment.author ?? 'you'
241 threads.push({
242 id: comment.id,
243 kind: 'code',
244 path,
245 start: comment.start_line ?? 0,
246 end: comment.end_line ?? comment.start_line ?? 0,
247 quote: comment.quote || comment.anchor || '',
248 body: comment.body ?? '',
249 author,
250 fromClaude: isClaude(author),
251 at: comment.created_at ?? '',
252 resolved: comment.resolved === true,
253 replies: (comment.replies ?? []).map(
254 (reply): Reply => ({
255 id: reply.id ?? '',
256 author: reply.author ?? 'you',
257 body: reply.body ?? '',
258 at: reply.created_at ?? '',
259 fromClaude: isClaude(reply.author ?? ''),
260 }),
261 ),
262 })
263 }
264 }
265
266 return threads
267}
268
269// ── The conversation as a crit plan ─────────────────────────────────────
270
271/** The plan a session's chat comments live in. */
272export const chatPlanSlug = (sessionId: string) => `claude-chat-${sessionId.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase()}`
273
274/** What a chat row adds before its text in the saved markdown. */
275export function chatLinePrefix(row: Row): string {
276 if (row.kind === 'title') return '### '
277 if (row.role === 'tool' || row.kind === 'note') return '> '
278 return ''
279}
280
281/**
282 * The conversation as markdown for `crit plan`: row i is line i + 1, so a
283 * comment's line numbers in crit are row indices plus one.
284 */
285export function chatMarkdownOf(doc: Doc): string {
286 return doc.rows.map(row => `${chatLinePrefix(row)}${row.text}`.replace(/\s+$/, '')).join('\n') + '\n'
287}
288
289/** Plan threads (lines of the saved markdown) put back on their messages. */
290export function chatThreadsFromPlan(planThreads: readonly Thread[], rows: readonly Row[]): Thread[] {
291 return planThreads.map(thread => {
292 const first = rows[thread.start - 1]
293 const last = rows[thread.end - 1]
294 if (!last?.path) {
295 return { ...thread, kind: 'chat' }
296 }
297 const start = first?.path === last.path ? (first.line ?? 0) : (last.line ?? 0)
298 return { ...thread, kind: 'chat', path: last.path, start, end: last.line ?? 0 }
299 })
300}
301
302// ── Review state ────────────────────────────────────────────────────────
303
304export type Pending = {
305 comments: Thread[]
306 replies: { thread: Thread; reply: Reply }[]
307 unread: number
308}
309
310/** What the person wrote that Claude has not seen, and what Claude wrote that the person has not. */
311export function pendingOf(threads: readonly Thread[], sent: readonly string[], seen: readonly string[]): Pending {
312 const isSent = new Set(sent)
313 const isSeen = new Set(seen)
314 const comments: Thread[] = []
315 const replies: { thread: Thread; reply: Reply }[] = []
316 let unread = 0
317
318 for (const thread of threads) {
319 if (thread.fromClaude && !isSeen.has(thread.id)) {
320 unread += 1
321 }
322 for (const reply of thread.replies) {
323 if (reply.fromClaude && !isSeen.has(reply.id)) {
324 unread += 1
325 }
326 }
327 if (thread.resolved) {
328 continue
329 }
330 if (!thread.fromClaude && !isSent.has(thread.id)) {
331 comments.push(thread)
332 }
333 for (const reply of thread.replies) {
334 if (!reply.fromClaude && !isSent.has(reply.id) && reply.id !== '') {
335 replies.push({ thread, reply })
336 }
337 }
338 }
339
340 return { comments, replies, unread }
341}
342
343/** Every Claude-authored id, to mark as seen. */
344export const claudeIdsOf = (threads: readonly Thread[]) =>
345 threads.flatMap(thread => [
346 ...(thread.fromClaude ? [thread.id] : []),
347 ...thread.replies.filter(reply => reply.fromClaude).map(reply => reply.id),
348 ])
349
350export function whereOf(thread: Pick<Thread, 'path' | 'start' | 'end'>, index: ChatIndex = []): string {
351 const lines =
352 thread.end === 0 ? 'as a whole' : thread.start === thread.end ? `line ${thread.start}` : `lines ${Math.max(1, thread.start)}-${thread.end}`
353 const chat = /^chat:(\d+)(?:\/(.+))?$/.exec(thread.path)
354 if (!chat) {
355 return thread.start > 0 ? `${thread.path}:${thread.start}${thread.end !== thread.start ? `-${thread.end}` : ''}` : thread.path
356 }
357 const role = index.find(entry => entry.index === Number(chat[1]))?.role
358 const whose = role === 'user' ? 'my message' : 'your reply'
359 return chat[2] ? `tool output in message #${chat[1]}, ${lines}` : `${whose} #${chat[1]}${thread.end === 0 ? ' ' : ', '}${lines}`
360}
361
362const quoted = (text: string) =>
363 text
364 .split('\n')
365 .slice(0, 8)
366 .map(line => ` > ${line}`)
367 .join('\n')
368
369/** The review as one prompt for Claude. */
370export function feedbackOf(pending: Pending, index: ChatIndex): string {
371 const parts: string[] = []
372 let n = 0
373
374 for (const thread of pending.comments) {
375 n += 1
376 parts.push(
377 [`${n}. ${whereOf(thread, index)} [id ${thread.id}]`, thread.quote ? quoted(thread.quote) : '', ` ${thread.body}`]
378 .filter(Boolean)
379 .join('\n'),
380 )
381 }
382
383 for (const { thread, reply } of pending.replies) {
384 n += 1
385 const before = thread.replies.filter(one => one.id !== reply.id && one.at <= reply.at).at(-1)
386 parts.push(
387 [
388 `${n}. Reply in thread [id ${thread.id}] on ${whereOf(thread, index)}`,
389 ` Thread: ${thread.body}`,
390 before ? ` ${before.fromClaude ? 'You' : 'I'} said: ${before.body}` : '',
391 ` My reply: ${reply.body}`,
392 ]
393 .filter(Boolean)
394 .join('\n'),
395 )
396 }
397
398 const counts = [
399 pending.comments.length ? `${pending.comments.length} comment${pending.comments.length === 1 ? '' : 's'}` : '',
400 pending.replies.length ? `${pending.replies.length} repl${pending.replies.length === 1 ? 'y' : 'ies'}` : '',
401 ]
402 .filter(Boolean)
403 .join(', ')
404
405 return [
406 `${REVIEW_HEAD} ${counts}.`,
407 '',
408 parts.join('\n\n'),
409 '',
410 `Reply to each item with the ${TOOL_PREFIX}reply tool: pass its id and a short reply. Make any code change an item asks for before you reply to it. I resolve threads myself. The ids are for the tool only: don't mention them in your answer to me.`,
411 ].join('\n')
412}
413
414// ── What the pane's Client draws ────────────────────────────────────────
415
416/** Characters of rows sent to the Client at once; its props are capped near 100k. */
417const WINDOW_CHARS = 60000
418const ROW_CHARS = 400
419
420/** The row each thread hangs under: the last row of its range, a `+`/context line first. */
421function anchorRowOf(rows: readonly Row[], thread: Thread): number {
422 let fallback = -1
423 for (let i = 0; i < rows.length; i += 1) {
424 const row = rows[i]
425 if (!row || row.path !== thread.path) {
426 continue
427 }
428 if (row.line === thread.end || (thread.end === 0 && row.kind === 'title')) {
429 if (row.sign !== '-') {
430 return i
431 }
432 fallback = fallback === -1 ? i : fallback
433 }
434 }
435 return fallback
436}
437
438/** The rows around `center` that fit the budget, as [from, to). */
439function windowOf(rows: readonly Row[], center: number, budget = WINDOW_CHARS): [number, number] {
440 const cost = (i: number) => Math.min(rows[i]?.text.length ?? 0, ROW_CHARS) + 40
441 const mid = Math.min(Math.max(center, 0), Math.max(0, rows.length - 1))
442 let from = mid
443 let to = Math.min(mid + 1, rows.length)
444 let spent = rows.length ? cost(mid) : 0
445 while (spent < budget && (from > 0 || to < rows.length)) {
446 if (to < rows.length) {
447 spent += cost(to)
448 to += 1
449 }
450 if (from > 0 && spent < budget) {
451 from -= 1
452 spent += cost(from)
453 }
454 }
455 return [from, to]
456}
457
458/** The props of the review Client: a window of rows, the threads hung in it, and the header. */
459export function clientPropsOf(args: {
460 doc: Doc
461 threads: readonly Thread[]
462 ui: UiState
463 sent: readonly string[]
464 seen: readonly string[]
465 status: string
466 toSend?: number
467 unread?: number
468}): ReviewProps {
469 const { doc, ui } = args
470 const sent = new Set(args.sent)
471 const seen = new Set(args.seen)
472 const [from, to] = windowOf(doc.rows, ui.center)
473 const rows: ClientRow[] = doc.rows.slice(from, to).map((row, n) => ({
474 i: from + n,
475 k: row.kind,
476 t: row.text.slice(0, ROW_CHARS),
477 c: row.path !== undefined,
478 ...(row.no ? { no: row.no } : {}),
479 ...(row.sign ? { s: row.sign } : {}),
480 ...(row.role ? { r: row.role } : {}),
481 }))
482
483 const head = (author: string, fromClaude: boolean, id: string, resolved: boolean) =>
484 [
485 fromClaude ? 'claude' : author === '' ? 'you' : author,
486 !fromClaude && !sent.has(id) && !resolved ? 'draft' : '',
487 fromClaude && !seen.has(id) ? 'new' : '',
488 ]
489 .filter(Boolean)
490 .join(' · ')
491
492 let orphans = 0
493 let hidden = 0
494 const threads: ClientThread[] = []
495 for (const thread of args.threads) {
496 const row = anchorRowOf(doc.rows, thread)
497 if (row === -1) {
498 orphans += 1
499 continue
500 }
501 if (row < from || row >= to) {
502 continue
503 }
504 const isNew = thread.replies.some(reply => reply.fromClaude && !seen.has(reply.id))
505 if (thread.resolved && ui.resolvedView === 'hidden' && !isNew) {
506 hidden += 1
507 continue
508 }
509 if (thread.resolved && ui.resolvedView !== 'full') {
510 const lastWord = thread.replies.at(-1)
511 threads.push({
512 id: thread.id,
513 row,
514 tone: isNew ? 'claude' : 'you',
515 head: `✓ resolved${isNew ? ' · new' : ''} · ${thread.body.split('\n')[0]?.slice(0, 40) ?? ''}${lastWord ? ` → ${lastWord.fromClaude ? 'claude' : 'you'}: ${lastWord.body.split('\n')[0]?.slice(0, 80) ?? ''}` : ''}`,
516 body: '',
517 replies: [],
518 collapsed: true,
519 })
520 continue
521 }
522 threads.push({
523 id: thread.id,
524 row,
525 tone: thread.fromClaude ? 'claude' : sent.has(thread.id) || thread.resolved ? 'you' : 'draft',
526 head: `${head(thread.author, thread.fromClaude, thread.id, thread.resolved)}${thread.resolved ? ' · resolved ✓' : ''}`,
527 body: thread.body,
528 replies: thread.replies.map(reply => ({
529 tone: reply.fromClaude ? 'claude' : sent.has(reply.id) ? 'you' : 'draft',
530 head: head(reply.author, reply.fromClaude, reply.id, thread.resolved),
531 body: reply.body,
532 })),
533 })
534 }
535
536 return {
537 docKey: `${ui.source}:${ui.docRev}`,
538 startAt: ui.startAt,
539 source: ui.source,
540 title: doc.title,
541 status: [args.status, orphans ? `${orphans} off-view` : '', hidden ? `${hidden} resolved hidden (h)` : ''].filter(Boolean).join(' · '),
542 toSend: args.toSend ?? 0,
543 unread: args.unread ?? 0,
544 error: doc.error ?? null,
545 total: doc.rows.length,
546 rows,
547 threads,
548 wheel: ui.wheel,
549 wheelBy: ui.wheelBy,
550 }
551}
552
553// ── Selection ───────────────────────────────────────────────────────────
554
555/** The last title row before `from`, or the first row. */
556export function sectionBefore(rows: readonly Row[], from: number): number {
557 for (let i = Math.min(from, rows.length) - 1; i >= 0; i -= 1) {
558 if (rows[i]?.kind === 'title') {
559 return i
560 }
561 }
562 return 0
563}
564
565/**
566 * The anchor a comment on rows `lo`..`hi` takes, or why it can't. With
567 * columns (a mouse selection) the quote is the exact text: from `startCol`
568 * in the first row to `endCol` in the last.
569 */
570export function selectionOf(
571 doc: Doc,
572 lo: number,
573 hi: number,
574 cols: { startCol: number | null; endCol: number | null } = { startCol: null, endCol: null },
575): { path: string; start: number; end: number; quote: string; isExact: boolean } | string {
576 const picked = doc.rows
577 .map((row, i) => ({ row, i }))
578 .slice(Math.min(lo, hi), Math.max(lo, hi) + 1)
579 .filter(({ row }) => row.path !== undefined)
580 if (picked.length === 0) {
581 return 'Move to a line to comment on it.'
582 }
583 const path = picked[picked.length - 1]?.row.path as string
584 const same = picked.filter(({ row }) => row.path === path)
585 // A title row (line 0: the whole message or file) only counts when nothing else is picked.
586 const numbered = same.map(({ row }) => row.line ?? 0).filter(line => line > 0)
587 const lines = numbered.length > 0 ? numbered : [0]
588 const isExact = cols.startCol !== null && cols.endCol !== null
589 const quote = same
590 .filter(({ row }) => row.kind === 'line')
591 .map(({ row, i }) => {
592 if (isExact) {
593 const from = i === Math.min(lo, hi) ? (cols.startCol ?? 0) : 0
594 const to = i === Math.max(lo, hi) ? (cols.endCol ?? row.text.length) : row.text.length
595 return row.text.slice(from, to)
596 }
597 return row.sign && row.sign !== ' ' ? `${row.sign}${row.text}` : row.text
598 })
599 .join('\n')
600 return { path, start: Math.min(...lines), end: Math.max(...lines), quote, isExact }
601}
602
603const collapse = (text: string) => text.replace(/\s+/g, ' ')
604
605/**
606 * Where an exact quote starts in its lines' text, as crit's web UI records
607 * `quote_offset`: the lines trimmed and joined by newlines, whitespace
608 * collapsed. Undefined when the quote is not found there.
609 */
610export function quoteOffsetOf(lines: readonly string[], startCol: number, quote: string): number | undefined {
611 const first = lines[0] ?? ''
612 const lead = first.length - first.trimStart().length
613 const full = collapse(lines.map(line => line.trim()).join('\n'))
614 const wanted = collapse(quote)
615 const guess = collapse(first.trimStart().slice(0, Math.max(0, startCol - lead))).length
616 if (full.slice(guess, guess + wanted.length) === wanted) return guess
617 const found = full.indexOf(wanted)
618 return found === -1 ? undefined : found
619}
620
621export function newId(): string {
622 const bytes = new Uint8Array(4)
623 crypto.getRandomValues(bytes)
624 return 'c' + [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
625}
626hooks/client.tsx 524 lines1import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
2
3import type { ClientRow, ClientThread, ReviewProps } from '../types'
4
5// The review pane's interactive region. It owns the cursor, the selection,
6// scrolling and the comment box, and posts every change to the review to
7// the hooks module, which owns the data.
8
9type Mode = 'browse' | 'comment' | 'reply'
10
11type State = {
12 docKey: string
13 cursor: number
14 mark: number | null
15 /** Columns of a mouse selection, in the mark row and the cursor row; null selects whole rows. */
16 markCol: number | null
17 cursorCol: number | null
18 isMarking: boolean
19 top: number
20 mode: Mode
21 draft: string
22 replyTo: string | null
23 thread: string | null
24 isDragging: boolean
25 wheel: number
26 want: number | null
27 asked: string
28 showHelp: boolean
29}
30
31type Line =
32 | { kind: 'row'; row: ClientRow }
33 | { kind: 'thread'; id: string; text: string; tone: string }
34 | { kind: 'composer'; text: string; isCursor: boolean }
35
36const HEADER = 2
37const FOOTER = 1
38const EDGE = 40
39
40const HELP = [
41 'click give the pane the keys (Esc gives them back)',
42 'click/drag move the cursor / select rows',
43 'j k ↑ ↓ move shift+↑ ↓ or v select rows',
44 'space pgup page g G home end top, bottom',
45 'n p → ← next, previous message or file',
46 'c enter comment on the line or selection',
47 'l tab next thread click a thread pick it',
48 'r reply to the picked thread, or the one on this line',
49 'x resolve it h resolved: one line, hidden, full',
50 's send comments and replies to Claude',
51 'a ask Claude to review the diff and comment',
52 '1 2 3 chat, diff, file f reload',
53 '? close this help',
54]
55
56const TONE: Record<string, string> = { you: 'cyan', claude: 'magenta', draft: 'yellow', meta: 'gray' }
57
58// One review pane, one instance: its state lives here. The engine ignores a
59// setState made while drawing, so handlers write here and call setState only
60// to ask for the redraw.
61const live: { props: ReviewProps | null; surface: ClientSurface<State> | null; lines: Line[]; state: State | null } = {
62 props: null,
63 surface: null,
64 lines: [],
65 state: null,
66}
67
68function commit(next: State) {
69 live.state = next
70 live.surface?.setState(next)
71}
72
73function wrap(text: string, width: number): string[] {
74 const out: string[] = []
75 const room = Math.max(8, width)
76 for (const para of text.split('\n')) {
77 let line = ''
78 for (const word of para.split(/\s+/)) {
79 if (word === '') continue
80 if (line === '') line = word
81 else if (line.length + 1 + word.length <= room) line += ' ' + word
82 else {
83 out.push(line)
84 line = word
85 }
86 while (line.length > room) {
87 out.push(line.slice(0, room))
88 line = line.slice(room)
89 }
90 }
91 out.push(line)
92 }
93 return out
94}
95
96/** The comment or reply box, drawn where its thread will be. */
97function composerLines(state: State, isReply: boolean): Line[] {
98 const drafted = state.draft.split('\n')
99 const shown = drafted.slice(-COMPOSER_MAX)
100 const lines: Line[] = [{ kind: 'composer', text: isReply ? '├ your reply' : '┌ new comment', isCursor: false }]
101 if (drafted.length > shown.length) lines.push({ kind: 'composer', text: `│ … ${drafted.length - shown.length} more lines`, isCursor: false })
102 shown.forEach((text, n) => lines.push({ kind: 'composer', text: `│ ${text}`, isCursor: n === shown.length - 1 }))
103 if (!isReply) lines.push({ kind: 'composer', text: '└', isCursor: false })
104 return lines
105}
106
107/** Where a row's text starts in the region: the lead, then the gutter or the title rule. */
108const textStart = (row: ClientRow) => 1 + (row.k === 'title' ? 3 : 7)
109
110const colOf = (row: ClientRow, x: number) => Math.min(Math.max(x - textStart(row), 0), row.t.length)
111
112/** The selection in reading order: rows lo..hi, and columns when the mouse made it. */
113function rangeOf(state: State) {
114 const mark = state.mark ?? state.cursor
115 const isForward = mark < state.cursor || (mark === state.cursor && (state.markCol ?? 0) <= (state.cursorCol ?? 0))
116 const hasCols = state.mark !== null && state.markCol !== null && state.cursorCol !== null
117 return isForward
118 ? { lo: mark, hi: state.cursor, loCol: hasCols ? state.markCol : null, hiCol: hasCols ? state.cursorCol : null }
119 : { lo: state.cursor, hi: mark, loCol: hasCols ? state.cursorCol : null, hiCol: hasCols ? state.markCol : null }
120}
121
122/** The part of row `i` the selection covers, as [from, to) columns, or null. */
123function selectedIn(state: State, row: ClientRow): [number, number] | null {
124 if (state.mark === null) return null
125 const { lo, hi, loCol, hiCol } = rangeOf(state)
126 if (row.i < lo || row.i > hi) return null
127 const from = row.i === lo && loCol !== null ? loCol : 0
128 const to = row.i === hi && hiCol !== null ? hiCol : row.t.length
129 return [from, Math.max(from, to)]
130}
131
132const selectionEnd = (state: State) => Math.max(state.mark ?? state.cursor, state.cursor)
133
134function linesOf(props: ReviewProps, width: number, state: State): Line[] {
135 const hung = new Map<number, ClientThread[]>()
136 for (const thread of props.threads) {
137 hung.set(thread.row, [...(hung.get(thread.row) ?? []), thread])
138 }
139 const inner = width - 10
140 const lines: Line[] = []
141 const isReplying = (id: string) => state.mode === 'reply' && state.replyTo === id
142 for (const row of props.rows) {
143 lines.push({ kind: 'row', row })
144 for (const thread of hung.get(row.i) ?? []) {
145 if (thread.collapsed) {
146 lines.push({ kind: 'thread', id: thread.id, tone: thread.tone === 'claude' ? 'claude' : 'meta', text: `· ${thread.head}` })
147 if (isReplying(thread.id)) lines.push(...composerLines(state, true), { kind: 'composer', text: '└', isCursor: false })
148 continue
149 }
150 lines.push({ kind: 'thread', id: thread.id, tone: thread.tone, text: `┌ ${thread.head}` })
151 for (const text of wrap(thread.body, inner)) lines.push({ kind: 'thread', id: thread.id, tone: thread.tone === 'claude' ? 'claude' : 'you', text: `│ ${text}` })
152 for (const reply of thread.replies) {
153 lines.push({ kind: 'thread', id: thread.id, tone: reply.tone, text: `├ ${reply.head}` })
154 for (const text of wrap(reply.body, inner)) lines.push({ kind: 'thread', id: thread.id, tone: reply.tone === 'claude' ? 'claude' : 'you', text: `│ ${text}` })
155 }
156 if (isReplying(thread.id)) lines.push(...composerLines(state, true))
157 lines.push({ kind: 'thread', id: thread.id, tone: 'meta', text: '└' })
158 }
159 if (state.mode === 'comment' && row.i === selectionEnd(state)) lines.push(...composerLines(state, false))
160 }
161 return lines
162}
163
164const first = (props: ReviewProps) => props.rows[0]?.i ?? 0
165const last = (props: ReviewProps) => props.rows.at(-1)?.i ?? 0
166
167const COMPOSER_MAX = 6
168
169function bodyRows(surface: ClientSurface<State>) {
170 return Math.max(3, surface.rows - HEADER - FOOTER)
171}
172
173/** Keeps the cursor row, and the threads under it where they fit, in the window. */
174function scrolled(state: State, lines: Line[], height: number): State {
175 const focus = state.mode === 'comment' ? selectionEnd(state) : state.cursor
176 let at = lines.findIndex(line => line.kind === 'row' && line.row.i === focus)
177 if (at === -1) return state
178 let end = at
179 while (end + 1 < lines.length && lines[end + 1]?.kind === 'thread' && end - at < height - 2) end += 1
180 const box = lines.findLastIndex(line => line.kind === 'composer')
181 if (box !== -1) {
182 end = box
183 at = Math.max(Math.min(at, box), box - height + 1)
184 }
185 let top = Math.min(state.top, Math.max(0, lines.length - height))
186 if (at < top) top = at
187 else if (end >= top + height) top = end - height + 1
188 return top === state.top ? state : { ...state, top: Math.max(0, top) }
189}
190
191function post(data: Record<string, string | number | boolean | null>) {
192 live.surface?.post(data)
193}
194
195/** Moves the cursor, asking the hooks module for more rows near the edges. */
196function moved(state: State, to: number, extend: boolean): State {
197 const props = live.props
198 if (!props) return state
199 const target = Math.min(Math.max(to, 0), Math.max(0, props.total - 1))
200 const cursor = Math.min(Math.max(target, first(props)), last(props))
201 const nearEdge = (cursor - first(props) < EDGE && first(props) > 0) || (last(props) - cursor < EDGE && last(props) < props.total - 1)
202 const want = target !== cursor ? target : null
203 if (nearEdge || want !== null) {
204 post({ type: 'window', center: want ?? cursor })
205 }
206 return {
207 ...state,
208 cursor,
209 want,
210 thread: null,
211 mark: extend || state.isMarking ? (state.mark ?? state.cursor) : null,
212 markCol: null,
213 cursorCol: null,
214 }
215}
216
217function sectionFrom(props: ReviewProps, from: number, step: 1 | -1): number {
218 const rows = step === 1 ? props.rows.filter(row => row.i > from) : props.rows.filter(row => row.i < from).reverse()
219 return rows.find(row => row.k === 'title')?.i ?? (step === 1 ? last(props) : first(props))
220}
221
222function threadTarget(state: State): string | null {
223 const props = live.props
224 if (!props) return null
225 if (state.thread) return state.thread
226 return props.threads.filter(thread => thread.row === state.cursor).at(-1)?.id ?? null
227}
228
229function onBrowseKey(state: State, event: ClientKeyEvent, page: number): State {
230 const props = live.props
231 if (!props) return state
232 const { key } = event
233 const extend = event.shift === true
234
235 if (key === 'down' || key === 'j' || key === 'J') return moved(state, state.cursor + 1, extend)
236 if (key === 'up' || key === 'k' || key === 'K') return moved(state, state.cursor - 1, extend)
237 if (key === 'pagedown' || (event.ctrl && key === 'd') || key === ' ' || key === 'space') return moved(state, state.cursor + page, extend)
238 if (key === 'pageup' || (event.ctrl && key === 'u')) return moved(state, state.cursor - page, extend)
239 if (key === 'home' || key === 'g') return moved(state, 0, extend)
240 if (key === 'end' || key === 'G') return moved(state, props.total - 1, extend)
241 if (key === 'n' || key === 'right') return moved(state, sectionFrom(props, state.cursor, 1), false)
242 if (key === 'p' || key === 'left') return moved(state, sectionFrom(props, state.cursor, -1), false)
243 if (key === 'v') return state.isMarking ? { ...state, isMarking: false, mark: null, markCol: null, cursorCol: null } : { ...state, isMarking: true, mark: state.cursor, markCol: null, cursorCol: null }
244
245 if (key === 'l' || key === 'tab') {
246 const ids = props.threads
247 if (ids.length === 0) return state
248 const now = ids.findIndex(thread => thread.id === state.thread)
249 const next = ids[(now + 1) % ids.length] ?? ids[0]
250 return next ? { ...moved(state, next.row, false), thread: next.id } : state
251 }
252
253 if (key === 'c' || key === 'return') {
254 const lo = Math.min(state.mark ?? state.cursor, state.cursor)
255 const hi = Math.max(state.mark ?? state.cursor, state.cursor)
256 const can = props.rows.some(row => row.i >= lo && row.i <= hi && row.c)
257 return can ? { ...state, mode: 'comment', draft: '' } : state
258 }
259 if (key === 'r') {
260 const id = threadTarget(state)
261 return id ? { ...state, mode: 'reply', draft: '', replyTo: id } : state
262 }
263 if (key === 'x') {
264 const id = threadTarget(state)
265 if (id) post({ type: 'resolve', id })
266 return state
267 }
268 if (key === '?') return { ...state, showHelp: !state.showHelp }
269 if (key === 's') post({ type: 'send' })
270 if (key === 'a') post({ type: 'ask' })
271 if (key === 'h') post({ type: 'toggleResolved' })
272 if (key === 'f') post({ type: 'refresh' })
273 if (key === '1' || key === '2' || key === '3') post({ type: 'source', source: ['chat', 'diff', 'file'][Number(key) - 1] ?? 'chat' })
274 return state
275}
276
277// ctrl+c never reaches a Client (Claude Code takes it), nor does Escape, so
278// an empty Enter, a Backspace on an empty box, or the cancel button cancels.
279const cancelled = (state: State): State => ({ ...state, mode: 'browse', draft: '', replyTo: null })
280
281const ERASE = new Set(['backspace', 'delete', 'Backspace', 'Delete', '\x7f', '\b'])
282
283function onComposeKey(state: State, event: ClientKeyEvent): State {
284 const { key } = event
285 const isErase = ERASE.has(key) || (event.ctrl === true && key === 'h')
286 if (key === 'return' && !event.shift) {
287 const body = state.draft.trim()
288 if (body !== '') {
289 if (state.mode === 'reply' && state.replyTo) {
290 post({ type: 'reply', id: state.replyTo, body })
291 } else {
292 const { lo, hi, loCol, hiCol } = rangeOf(state)
293 post({ type: 'comment', start: lo, end: hi, startCol: loCol, endCol: hiCol, body })
294 }
295 }
296 return body === '' ? { ...state, mode: 'browse', draft: '', replyTo: null } : { ...state, mode: 'browse', draft: '', replyTo: null, mark: null, markCol: null, cursorCol: null, isMarking: false }
297 }
298 if (key === 'return') return { ...state, draft: state.draft + '\n' }
299 if (event.ctrl && key === 'u') return { ...state, draft: '' }
300 if (isErase && state.draft === '') return cancelled(state)
301 if (isErase) return { ...state, draft: [...state.draft].slice(0, -1).join('') }
302 if (key === 'space') return { ...state, draft: state.draft + ' ' }
303 if ([...key].length === 1 && key >= ' ' && !event.ctrl && !event.meta) return { ...state, draft: state.draft + key }
304 return state
305}
306
307/** Tells the hooks module Claude's new words were on screen when the person acted. */
308function noteSeen() {
309 if ((live.props?.unread ?? 0) > 0) post({ type: 'seen' })
310}
311
312function onKey(event: ClientKeyEvent) {
313 noteSeen()
314 const surface = live.surface
315 const state = live.state
316 if (!surface || !state) return
317 const page = Math.max(1, Math.floor(bodyRows(surface) / 2))
318 const next = state.mode === 'browse' ? onBrowseKey(state, event, page) : onComposeKey(state, event)
319 if (next !== state) commit(next)
320}
321
322function onPointer(event: ClientPointerEvent) {
323 const surface = live.surface
324 const state = live.state
325 if (!surface || !state || state.mode !== 'browse') return
326
327 const height = bodyRows(surface)
328 let top = state.top
329 if (event.type === 'move' && state.isDragging) {
330 if (event.y < HEADER && top > 0) top -= 1
331 if (event.y >= HEADER + height && top + height < live.lines.length) top += 1
332 }
333 const y = Math.min(Math.max(event.y - HEADER, 0), height - 1)
334 const line = live.lines[top + y]
335
336 if (event.type === 'down' && event.button === 'left') {
337 noteSeen()
338 if (event.y < HEADER || event.y >= HEADER + height || !line) return
339 if (line.kind === 'thread') {
340 const thread = live.props?.threads.find(one => one.id === line.id)
341 commit({ ...state, thread: line.id, cursor: thread?.row ?? state.cursor, mark: null, markCol: null, cursorCol: null, isMarking: false })
342 return
343 }
344 if (line.kind !== 'row') return
345 const col = colOf(line.row, event.x)
346 commit(
347 event.shift
348 ? { ...state, cursor: line.row.i, cursorCol: col, mark: state.mark ?? state.cursor, markCol: state.markCol ?? (state.mark === null ? 0 : null), isDragging: true, thread: null }
349 : { ...state, cursor: line.row.i, cursorCol: col, mark: line.row.i, markCol: col, isMarking: false, isDragging: true, thread: null },
350 )
351 return
352 }
353
354 if (event.type === 'move' && state.isDragging && line?.kind === 'row') {
355 const col = colOf(line.row, event.x)
356 if (line.row.i !== state.cursor || col !== state.cursorCol || top !== state.top) commit({ ...state, cursor: line.row.i, cursorCol: col, top })
357 return
358 }
359
360 if (event.type === 'up' && state.isDragging) {
361 // A click without a drag only moves the cursor.
362 const isClick = state.mark === state.cursor && state.markCol === state.cursorCol
363 commit(isClick ? { ...state, isDragging: false, mark: null, markCol: null, cursorCol: null } : { ...state, isDragging: false })
364 }
365}
366
367export default function Review(props: ReviewProps, surface: ClientSurface<State>) {
368 live.props = props
369 live.surface = surface
370 const { Box, Text, Button } = surface.elements
371
372 surface.onKey(onKey)
373 surface.onPointer(onPointer)
374 let state = live.state
375 if (!state) {
376 state = {
377 docKey: props.docKey,
378 cursor: props.startAt,
379 mark: null,
380 markCol: null,
381 cursorCol: null,
382 isMarking: false,
383 top: 0,
384 mode: 'browse',
385 draft: '',
386 replyTo: null,
387 thread: null,
388 isDragging: false,
389 wheel: props.wheel,
390 want: null,
391 asked: '',
392 showHelp: false,
393 }
394 }
395
396 let next = state
397 if (props.docKey !== next.docKey) {
398 next = { ...next, docKey: props.docKey, cursor: props.startAt, mark: null, markCol: null, cursorCol: null, isMarking: false, top: 0, thread: null, want: null, mode: 'browse' }
399 }
400 if (next.want !== null && props.rows.some(row => row.i === next.want)) {
401 next = { ...next, cursor: next.want as number, want: null }
402 }
403 if (props.wheel !== next.wheel) {
404 next = { ...moved(next, next.cursor + props.wheelBy * 3, false), wheel: props.wheel }
405 }
406 if (props.rows.length > 0 && (next.cursor < first(props) || next.cursor > last(props))) {
407 next = { ...next, cursor: Math.min(Math.max(next.cursor, first(props)), last(props)) }
408 }
409
410 const width = Math.max(30, surface.columns || 80)
411 const height = bodyRows(surface)
412 const lines = linesOf(props, width, next)
413 live.lines = lines
414 next = scrolled(next, lines, height)
415 live.state = next
416 const view = next
417
418 const lo = Math.min(view.mark ?? view.cursor, view.cursor)
419 const hi = Math.max(view.mark ?? view.cursor, view.cursor)
420 const shown = lines.slice(view.top, view.top + height)
421
422 const chars = props.rows.reduce((sum, row) => {
423 const part = selectedIn(view, row)
424 return sum + (part ? part[1] - part[0] : 0)
425 }, 0)
426 const where =
427 view.mark === null ? `${view.cursor + 1}/${props.total}` : view.markCol !== null ? `${chars} characters selected` : `${hi - lo + 1} rows selected`
428 const counts = [props.unread ? `${props.unread} new from Claude` : '', props.status].filter(Boolean).join(' · ')
429
430 return (
431 <Box flexDirection="column" width={width}>
432 <Box flexDirection="row" gap={2}>
433 {(['chat', 'diff', 'file'] as const).map((source, n) => (
434 <Button
435 key={`tab-${source}`}
436 plain
437 label={`${n + 1} ${source}`}
438 dimColor={source !== props.source}
439 onPress={() => post({ type: 'source', source })}
440 />
441 ))}
442 <Box flexGrow={1}>
443 <Text dimColor wrap="truncate-end">
444 {props.title}
445 {counts ? ` · ${counts}` : ''}
446 </Text>
447 </Box>
448 {props.toSend > 0 ? (
449 <Button key="send" variant="primary" label={`send ${props.toSend} to Claude (s)`} onPress={() => post({ type: 'send' })} />
450 ) : null}
451 </Box>
452 <Text dimColor>{'─'.repeat(width)}</Text>
453 {props.error && props.rows.length === 0 ? <Text dimColor>{props.error}</Text> : null}
454 {view.showHelp ? HELP.map(line => <Text>{line}</Text>) : null}
455 {(view.showHelp ? [] : shown).map(line => {
456 if (line.kind === 'composer') {
457 return (
458 <Box flexDirection="row">
459 <Text color="yellow" wrap="truncate-start">
460 {' '}
461 {line.text}
462 {line.isCursor ? '█' : ''}
463 </Text>
464 {line.isCursor ? <Text> </Text> : null}
465 {line.isCursor ? <Button key="cancel" label="cancel" dimColor onPress={() => live.state && commit(cancelled(live.state))} /> : null}
466 </Box>
467 )
468 }
469 if (line.kind === 'thread') {
470 const isPicked = line.id === view.thread
471 return (
472 <Text color={TONE[line.tone]} dimColor={line.tone === 'meta'} inverse={isPicked && line.tone !== 'meta'} wrap="truncate-end">
473 {' '}
474 {line.text}
475 </Text>
476 )
477 }
478 const { row } = line
479 const isCursor = row.i === view.cursor
480 const picked = selectedIn(view, row)
481 const isWholeRow = picked !== null && view.markCol === null
482 const hasThread = props.threads.some(thread => thread.row === row.i)
483 const lead = isCursor ? '▶' : hasThread ? '●' : ' '
484 const gutter = row.k === 'title' ? '' : `${(row.no ?? '').padStart(4)} ${row.s ?? ' '} `
485 const color =
486 row.s === '+' ? 'green' : row.s === '-' ? 'red' : row.k === 'hunk' ? 'cyan' : row.r === 'user' ? 'blue' : row.r === 'assistant' && row.k === 'title' ? 'magenta' : undefined
487 const prefix = row.k === 'title' ? '── ' : gutter
488 const style = { bold: row.k === 'title', color, dimColor: row.r === 'tool' && row.k !== 'hunk' && !isCursor }
489 if (picked && !isWholeRow) {
490 // Draw before | selected | after, cut to the width by hand so the
491 // pieces line up with what the mouse pressed.
492 const room = width - 1
493 const full = `${prefix}${row.t}`.slice(0, room)
494 const from = Math.min(prefix.length + picked[0], full.length)
495 const to = Math.min(prefix.length + picked[1], full.length)
496 return (
497 <Box flexDirection="row">
498 <Text color={hasThread ? 'magenta' : undefined}>{lead}</Text>
499 <Text {...style}>{full.slice(0, from)}</Text>
500 <Text {...style} inverse>{full.slice(from, to)}</Text>
501 <Text {...style}>{full.slice(to) || ' '}</Text>
502 </Box>
503 )
504 }
505 return (
506 <Box flexDirection="row">
507 <Text color={hasThread ? 'magenta' : undefined}>{lead}</Text>
508 <Text {...style} inverse={(isCursor && view.mark === null) || isWholeRow} wrap="truncate-end">
509 {row.k === 'title' ? `── ${row.t} ` : `${gutter}${row.t || ' '}`}
510 </Text>
511 </Box>
512 )
513 })}
514 <Text dimColor wrap="truncate-end">
515 {view.mode !== 'browse'
516 ? 'enter save · shift+enter new line · empty enter or backspace cancels'
517 : view.mark !== null
518 ? `${where} · c comment · v or click clears`
519 : `c comment · r reply · x resolve · s send · ? keys · ${where}`}
520 </Text>
521 </Box>
522 )
523}
524types/index.d.ts 116 lines1/** Where a review looks: the conversation, the working-tree diff, or one file. */
2export type Source = 'chat' | 'diff' | 'file'
3
4/** One line of a review document. */
5export type Row = {
6 kind: 'title' | 'hunk' | 'line' | 'note'
7 text: string
8 /** Comment anchor: a repo path, or `chat:<message index>` for the conversation. */
9 path?: string
10 line?: number
11 sign?: '+' | '-' | ' '
12 role?: 'user' | 'assistant' | 'tool'
13 /** Gutter label (a line number). */
14 no?: string
15}
16
17export type Doc = { source: Source; title: string; rows: Row[]; error?: string }
18
19export type Reply = {
20 id: string
21 author: string
22 body: string
23 at: string
24 fromClaude: boolean
25}
26
27export type Thread = {
28 id: string
29 /** `chat` threads live in this mod; `code` threads live in crit's review.json. */
30 kind: 'chat' | 'code'
31 path: string
32 start: number
33 end: number
34 quote: string
35 body: string
36 author: string
37 fromClaude: boolean
38 at: string
39 resolved: boolean
40 replies: Reply[]
41}
42
43export type UiState = {
44 source: Source
45 /** Where the Client puts its cursor when a document (re)loads. */
46 startAt: number
47 /** The row the Client's window of rows is centred on. */
48 center: number
49 /** Resolved threads: one line each, hidden, or in full. */
50 resolvedView: 'line' | 'hidden' | 'full'
51 filePath: string | null
52 /** Bumped when the shown document changes, so the Client resets its cursor. */
53 docRev: number
54 /** Bumped per wheel tick over the pane; the Client moves its cursor by wheelBy. */
55 wheel: number
56 wheelBy: number
57}
58
59/** A row as the Client gets it: short keys keep the props small. */
60export type ClientRow = {
61 i: number
62 k: Row['kind']
63 t: string
64 /** Has a comment anchor. */
65 c: boolean
66 no?: string
67 s?: '+' | '-' | ' '
68 r?: 'user' | 'assistant' | 'tool'
69}
70
71export type ClientThread = {
72 id: string
73 row: number
74 tone: 'you' | 'claude' | 'draft'
75 head: string
76 body: string
77 replies: { tone: 'you' | 'claude' | 'draft'; head: string; body: string }[]
78 /** A resolved thread drawn as one line; h expands it. */
79 collapsed?: boolean
80}
81
82export type ReviewProps = {
83 docKey: string
84 startAt: number
85 source: Source
86 title: string
87 status: string
88 /** Comments and replies not yet sent to Claude. */
89 toSend: number
90 /** Claude's comments and replies not yet seen. */
91 unread: number
92 error: string | null
93 total: number
94 rows: ClientRow[]
95 threads: ClientThread[]
96 wheel: number
97 wheelBy: number
98}
99
100export type ChatIndex = { index: number; role: 'user' | 'assistant'; text: string }[]
101
102declare module 'claude-code' {
103 interface PluginState {
104 'crit-tui': {
105 threads: Thread[]
106 critThreads: Thread[]
107 sent: string[]
108 seen: string[]
109 ui: UiState
110 docs: { chat: Doc; diff: Doc; file: Doc }
111 chatIndex: ChatIndex
112 isOpen: boolean
113 }
114 }
115}
116