SLOPSHOPPER

crit-tui

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.

newpanebandrowsguardcommand
★ 1,194v0.1.0MITupdated 2026-10-07tomasz-tomczyk/crit/integrations/claude-code-tui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crit-tui
│ ┃ crit ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./client.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crit-tui │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · crit
▣ client module ./client.tsx
README

crit-tui

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.

Install

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

Use

  • /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 pane

The 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).

inputdoes
click a linemove the cursor there
dragselect rows
click a threadpick it for r / x
j k, ↑ ↓, wheelmove
shift+↑ ↓, or v then moveselect rows
space / pagedown, ctrl+u / pageuppage
g G, home endtop, bottom
n p, → ←next, previous message or file
l, tabnext thread
c, entercomment on the line or selection
rreply to the picked thread, or the one on this line
xresolve it
ssend drafts and replies to Claude
aask Claude to review the diff and leave comments
1 2 3conversation, diff, file
hshow or hide resolved threads
freload 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.

What Claude gets

Three tools:

  • mcp__crit-tui__reply { id, body, resolve? }: answer a comment
  • mcp__crit-tui__comment { file, line, end_line?, body }: leave a comment on code
  • mcp__crit-tui__threads { include_resolved? }: list open threads

send 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.

Where comments live

Everything goes to crit through its CLI, so the crit browser UI and crit comments see the same review.

  • Diff and file comments go to the repo's review file with 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).
  • Chat comments go to a crit plan named 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.
  • Claude's replies are written with --author Claude.
  • Without the crit CLI (setting critBin), all comments stay in the session.

How the pane is built

  • 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.
  • A Client's props are capped near 100k characters, so it gets a window of rows around the cursor and asks for a new window near the edges.
  • The engine ignores a 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.

Developing

claude plugin validate integrations/claude-code-tui
claude plugin test integrations/claude-code-tui

Limits of this early version

  • crit paths are taken as repo-root relative. Start Claude Code at the repo root.
  • Removed (-) 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.
  • Reopening a resolved crit thread is left to the crit browser UI.
  • The diff is git diff HEAD plus up to 20 untracked files.
Source 4 files
hooks/register.tsx 786 lines
1import { 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}
786
hooks/model.ts 626 lines
1import 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}
626
hooks/client.tsx 524 lines
1import 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}
524
types/index.d.ts 116 lines
1/** 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