SLOPSHOPPER

tui-review

/tui-review: a fast diff pane for the current branch, next to the chat, so you can ask Claude about what you read. Needs the brv binary.

newpaneguardcommandtoaststatus
v0.3.0no licenseupdated 2026-10-15deadmade/dead-claude-skills/plugins/tui-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tui-review
│ ┃ review ✕ › fix the failing auth ╭───────────────────────────────────────────╮ │ ┃ No review open. Run /tui-review. │ tui-review │ │ ⏺ Read(src/auth.ts) │ review: brv review ended without a review │ │ ⎿ 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 │ │ › /tui-review │ ⎿ tui-review: Opening a review of this branch against the branch i │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · review
No review open. Run /tui-review.
README

tui-review

/tui-review opens a diff pane beside the chat, so you can read a branch's changes and ask Claude about them in the same session. The diff, highlighting and git work happen in brv, a Rust binary. The mod only paints, from small binary chunk files brv writes to disk, so a 100k-line PR costs the same memory and frame time as a 100-line one.

Install

/plugin install tui-review@dead-claude-skills

It needs brv. The installer (npx --allow-git=root github:deadmade/dead-claude-skills#semver:*) downloads the build for your machine into ~/.claude/bin and sets the plugin's brvPath, unless a brv is already on PATH. By hand: unpack the brv-* archive for your platform from the releases, or build it, and put it on PATH or set its path with /plugin configure tui-review:

cargo build --release --manifest-path plugins/tui-review/brv/Cargo.toml # or: nix build .#brv

Use

/tui-review this branch against the branch it is stacked on /tui-review <base> against <base> (a branch, remote branch or commit); remembered for this branch /tui-review demo a generated 100k-line review

The base. For main → b1 → b2, reviewing b2 shows only b2's own commits: the base is the branch whose merge-base with HEAD is nearest. A base that moved on after you branched stays the base, and its new commits stay out (the diff is always merge-base..HEAD). Branches stacked on top of yours and your branch's own remote copy are never picked. The pane's title shows head ← base, so a wrong pick is visible at once; /tui-review <base> fixes it, and is saved as git config branch.<name>.brvBase. Only committed changes are shown.

Following HEAD. After each Bash command Claude runs, and when you come back to the pane, the review runs again. If HEAD has not moved, brv answers from its cache at once and nothing changes; after a commit, checkout or rebase the new review opens in place, on the file you were in, with your unsent comments moved onto it.

Claude knows where you are. Each prompt you send while the pane is open carries a note Claude reads and you don't see: which diff is open (git diff <merge-base>..<head>) and the cursor's path:line. So "why is this here?" means the line you are on.

The pane docks beside the chat under Claude Code's fullscreen renderer (/tui fullscreen, once) in a terminal at least 110 columns wide; otherwise it opens above the prompt. It takes the keyboard when it opens. Esc goes back to the prompt to type a question; ctrl+x tab goes back to the pane.

Key
j / kcursor down / up; the window follows it
mouse wheelscroll the window; the cursor stays in it
n / pcursor to the next / previous file (p inside a file goes to its own header first)
rmark the cursor's file viewed and collapse it to its header; on a viewed file, clear the mark and open it
ccomment on the cursor's line, or on the whole file from its header; on a commented line, edit the comment
ssend every comment to Claude as one message, then clear them
qclose the pane (so do the frame's close mark and ctrl+x x)

Closing the pane lets the review go; /tui-review opens it again. A new conversation (/clear, a resume, a new session) closes it too, and drops comments that were not sent.

"Viewed" marks persist across sessions, by the file's content: a rebase that leaves a file alone keeps its mark (and the file opens collapsed), a change to it clears it. The header bar counts them.

Lockfiles and minified files are listed with their +/− counts but not shown line by line; so are binary files. Code is coloured with tree-sitter for Rust, C#, TypeScript/TSX, JavaScript, Python, Nix, JSON, YAML, TOML and Bash; other files are plain. The text shows first and the colours follow a moment later.

Comments

Claude doesn't see the pane, so comments are how you point at code. c opens a field under the diff: Enter saves the comment, an empty one cancels it (or deletes the comment you were editing). A saved comment shows under its line, wrapped to the pane, with a ● in the line's gutter; the header bar and the send button count the comments waiting. s sends them all as one prompt, in your name: for each, path:line (or the file, for a comment on its header), the commented line with up to two lines before it from its hunk, and your text. Then they are cleared. Comments live in the session, so a reload of the mod keeps them.

The arrow keys don't scroll yet: the engine only forwards them while a pane has rows of its own to scroll.

The status line shows load · paint · chunks for the last frame.

brv

brv review [--base REF] [--repo DIR] [--cache DIR] what /tui-review runs brv gen [--lines N] [--out DIR] a synthetic review cache (what /tui-review demo runs) brv bench [--repo DIR] [--base REF] how long a review of a real repository takes, cold and cached

Reviews are cached in <git common dir>/brv/ (shared by a repo's worktrees), keyed by merge-base, HEAD and base; the five most recent are kept.

nix develop (or direnv) gives the Rust toolchain: cargo, clippy, rustfmt and rust-analyzer.

Develop

claude --plugin-dir plugins/tui-review # hot-reloads on save

cd plugins/tui-review/brv cargo clippy --all-targets && cargo test && cargo bench cargo test --release --test perf -- --ignored --nocapture # speed and memory budgets on a large fixture repo cargo run --release -- gen --out /tmp/brv-fixture && node ../tests/make-fixture.mjs /tmp/brv-fixture cd .. && claude plugin test . # format contract, pane, speed budgets

Plugin tests have no file system, so tests/make-fixture.mjs turns brv gen output into tests/fixture.gen.ts (not committed). The tests read the real Rust output, so they also hold the TypeScript reader to the Rust writer. The cache layout is documented in brv/src/format.rs.

brv ships with the repo's releases (see the main README): npm run release bumps brv/Cargo.toml, Cargo.lock and .claude-plugin/plugin.json with everything else (a test checks they match), and the tag's release.yml runs .github/workflows/build-brv.yml, which builds Windows, Linux (static, x64 and arm64) and macOS (arm64 and x64). Run build-brv.yml by hand to build without releasing.

Source 9 files
hooks/register.tsx 644 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register } from 'claude-code';
3
4import type { ReviewComment, TuiReviewOpen } from '../types';
5import { Highlights, Kind, Lines, Manifest, chunkName, fromBase64, toBase64 } from './chunks';
6import { message, upsert, wrap } from './comments';
7import { reviewContext } from './context';
8import { type Loaded, NOTE_TEXT, paint } from './paint';
9import { View } from './view';
10import { VIEWED_KEY, Viewed } from './viewed';
11
12const PANE = 'review';
13const RASTER = 'diff';
14/** Chunks kept in memory: the one or two the window spans plus one ahead. */
15const RESIDENT = 3;
16/** Diff lines quoted with a comment: the commented line and up to this many before it in its hunk. */
17const QUOTE_BEFORE = 2;
18
19const reviewAtom = atom({ plugin: 'tui-review', key: 'review' } as const, null as TuiReviewOpen | null);
20const commentsAtom = atom({ plugin: 'tui-review', key: 'comments' } as const, [] as ReviewComment[]);
21const composingAtom = atom({ plugin: 'tui-review', key: 'composing' } as const, null as ReviewComment | null);
22
23// What the pane draws from. Module memory, not $.state: it changes on every scroll, and a reload just reads it again.
24let manifest: { dir: string; value: Manifest } | undefined;
25/** Viewed files are the collapsed ones: `files` is the view's `collapsed`. */
26let viewed: { marks: Viewed; files: Uint8Array; count: number } | undefined;
27let view: View | undefined;
28/** What the view's comment rows were built from, so a frame rebuilds them only when a comment or the width changed. */
29let notesFrom = '';
30const resident = new Map<number, Loaded>();
31/** The view row drawn first, and the cursor's row. */
32let top = 0;
33let cursor = 0;
34let mounted: { cols: number; rows: number } | undefined;
35/** The frame's cells, reused while the size holds, so a scroll allocates nothing. */
36let cells = new Uint32Array(0);
37let painting: Promise<void> | undefined;
38let paintAgain = false;
39let stopRun: (() => void) | undefined;
40/** A brv run is reading; a refresh asked for meanwhile waits for it and runs once after. */
41let running = false;
42/** Bumped per run, so a run ended by a newer one does not mark the newer one finished. */
43let runId = 0;
44let refreshAgain = false;
45/** A new review whose comments from the last one still need moving onto it, once its chunks are written. */
46let relocateTo: string | undefined;
47/** Focus moves within the pane come in bursts; one refresh per this many ms is enough. */
48const FOCUS_REFRESH_MS = 2000;
49let focusRefreshAt = 0;
50
51/** How the running brv was started: what kind of review, and the base it was asked for. */
52type Origin = { kind: TuiReviewOpen['kind']; base?: string };
53
54type $ = EngineInterface;
55
56async function readBytes($: $, path: string) {
57  const { base64 } = await $.fs.read(path, { as: 'bytes' });
58  return fromBase64(base64);
59}
60
61async function loadManifest($: $, dir: string): Promise<Manifest> {
62  if (manifest?.dir !== dir) {
63    const value = new Manifest(await readBytes($, `${dir}/manifest.brv`));
64    const marks = Viewed.from(await $.store.get(VIEWED_KEY));
65    const files = new Uint8Array(value.files);
66    let count = 0;
67    for (let i = 0; i < value.files; i++) {
68      if (marks.has(value.blob(i))) {
69        files[i] = 1;
70        count++;
71      }
72    }
73    manifest = { dir, value };
74    viewed = { marks, files, count };
75    view = new View(value, files);
76    notesFrom = '';
77    resident.clear();
78  }
79  return manifest.value;
80}
81
82/** The chunks the window's rows show, in order, plus the next one. A collapsed file's row needs none. */
83function windowChunks(v: View, from: number, count: number): number[] {
84  const m = v.manifest;
85  const wanted: number[] = [];
86  for (let row = from, end = Math.min(v.rows, from + count); row < end; row++) {
87    if (v.collapsed[v.fileOfRow(row)] === 1) continue;
88    const c = Math.floor(v.lineOfRow(row) / m.chunkLines);
89    if (wanted.at(-1) !== c) wanted.push(c);
90  }
91  const ahead = (wanted.at(-1) ?? -1) + 1;
92  if (ahead > 0 && ahead < m.chunks) wanted.push(ahead);
93  return wanted;
94}
95
96/** Loads the chunks the window needs, plus the next, and evicts the rest down to RESIDENT. */
97async function ensureChunks($: $, dir: string, wanted: number[]) {
98  await Promise.all(
99    wanted.map(async (c) => {
100      const have = resident.get(c);
101      if (have?.hl !== undefined) return;
102      const lines =
103        have?.lines ?? (await readOptional($, `${dir}/${chunkName(c, 'lines')}`).then((b) => b && new Lines(b)));
104      if (lines === undefined) return;
105      const hlBytes = await readOptional($, `${dir}/${chunkName(c, 'hl')}`);
106      resident.set(c, { lines, hl: hlBytes && new Highlights(hlBytes) });
107    }),
108  );
109  for (const c of resident.keys()) {
110    if (resident.size <= RESIDENT) break;
111    if (!wanted.includes(c)) resident.delete(c);
112  }
113}
114
115/** A chunk brv has not written yet reads as undefined. */
116async function readOptional($: $, path: string) {
117  return (await $.fs.exists(path)) ? readBytes($, path) : undefined;
118}
119
120/** Paints the frame for the mounted size; undefined until the pane has drawn once. */
121async function frame($: $): Promise<string | undefined> {
122  const review = await $.state.get({ plugin: 'tui-review', key: 'review' });
123  if (mounted === undefined || !review.value) return undefined;
124  const started = performance.now();
125  await loadManifest($, review.value.dir);
126  const v = view!;
127  const { cols, rows } = mounted;
128  const drafts = (await $.state.get({ plugin: 'tui-review', key: 'comments' })).value ?? [];
129  const here = drafts.filter((c) => c.dir === review.value!.dir);
130  syncNotes(v, here, cols);
131  await ensureChunks($, review.value.dir, windowChunks(v, top, rows - 1));
132  const loadedAt = performance.now();
133  if (cells.length !== cols * rows * 3) cells = new Uint32Array(cols * rows * 3);
134  paint(cells, cols, rows, top, {
135    view: v,
136    cursor,
137    viewed,
138    comments: { lines: new Set(here.map((c) => c.line)), count: drafts.length },
139    chunk: (c) => resident.get(c),
140  });
141  const encoded = toBase64(new Uint8Array(cells.buffer));
142  const done = performance.now();
143  $.ui.status(
144    `review · load ${(loadedAt - started).toFixed(1)}ms · paint ${(done - loadedAt).toFixed(1)}ms · ${resident.size} chunks`,
145  );
146  return encoded;
147}
148
149/** Repaints in place with $.ui.blit; scrolls that land while a paint runs fold into one more paint. */
150function repaint($: $): Promise<void> {
151  if (painting !== undefined) {
152    paintAgain = true;
153    return painting;
154  }
155  painting = (async () => {
156    do {
157      paintAgain = false;
158      const next = await frame($);
159      if (next === undefined) break;
160      const blit = await $.ui.blit({ requestId: PANE, key: RASTER, cells: next });
161      if (blit.deny !== undefined) {
162        $.ui.invalidate('ui.render');
163        break;
164      }
165    } while (paintAgain);
166  })().finally(() => {
167    painting = undefined;
168  });
169  return painting;
170}
171
172/** Shows each comment under its line, wrapped to the pane, keeping the cursor on its line. */
173function syncNotes(v: View, comments: readonly ReviewComment[], cols: number) {
174  const from = JSON.stringify([cols, comments.map((c) => [c.line, c.text])]);
175  if (from === notesFrom) return;
176  notesFrom = from;
177  const width = Math.max(10, cols - NOTE_TEXT - 1);
178  const keep = { line: v.lineOfRow(cursor), above: cursor - top };
179  v.notes = new Map(comments.map((c) => [c.line, wrap(c.text, width)]));
180  v.rebuild();
181  cursor = v.rowOfLine(keep.line);
182  top = cursor - keep.above;
183  follow();
184}
185
186/** Rows of diff the window shows, below the header bar. */
187const bodyRows = () => Math.max(1, (mounted?.rows ?? 2) - 1);
188
189function maxTop(): number {
190  return Math.max(0, (view?.rows ?? 0) - bodyRows());
191}
192
193/** Keeps `top` in range and the cursor inside the window, moving the window to it. */
194function follow() {
195  const last = Math.max(0, (view?.rows ?? 0) - 1);
196  cursor = Math.min(last, Math.max(0, cursor));
197  if (cursor < top) top = cursor;
198  if (cursor >= top + bodyRows()) top = cursor - bodyRows() + 1;
199  top = Math.min(maxTop(), Math.max(0, top));
200}
201
202function moveCursor($: $, row: number) {
203  const before = { top, cursor };
204  cursor = Math.round(row);
205  follow();
206  if (top !== before.top || cursor !== before.cursor) void repaint($);
207}
208
209/** The mouse wheel moves the window; the cursor stays on its row while that row stays in view. */
210function scrollTo($: $, row: number) {
211  const before = { top, cursor };
212  top = Math.min(maxTop(), Math.max(0, Math.round(row)));
213  cursor = Math.min(top + bodyRows() - 1, Math.max(top, cursor));
214  follow();
215  if (top !== before.top || cursor !== before.cursor) void repaint($);
216}
217
218/** The cursor to the next or previous file's header, shown at the top of the window. */
219function fileJump($: $, by: 1 | -1) {
220  const v = view;
221  if (v === undefined || v.manifest.files === 0) return;
222  const at = v.fileOfRow(cursor);
223  // Back from inside a file goes to its own header first.
224  const target = by < 0 && v.headerRow(at) < cursor ? at : Math.min(v.manifest.files - 1, Math.max(0, at + by));
225  cursor = v.headerRow(target);
226  top = cursor;
227  follow();
228  void repaint($);
229}
230
231/** Marks the cursor's file viewed, collapsing it to its header, or clears the mark and opens it again. */
232async function toggleViewed($: $) {
233  const v = view;
234  if (v === undefined || viewed === undefined || v.manifest.files === 0) return;
235  const index = v.fileOfRow(cursor);
236  const isViewed = viewed.marks.toggle(v.manifest.blob(index));
237  await $.store.set(VIEWED_KEY, viewed.marks.stored());
238  viewed.files[index] = isViewed ? 1 : 0;
239  viewed.count += isViewed ? 1 : -1;
240  v.rebuild();
241  cursor = v.headerRow(index);
242  follow();
243  await repaint($);
244}
245
246/** The comment the cursor's line would get: where it is and the diff lines it quotes; text still empty. */
247function commentAtCursor(dir: string): ReviewComment | string {
248  const v = view;
249  if (v === undefined || v.manifest.files === 0) return 'nothing to comment on';
250  const m = v.manifest;
251  const file = v.fileOfRow(cursor);
252  const line = v.lineOfRow(cursor);
253  const path = m.file(file).path;
254  const whole = { dir, line, path, lineNo: null, side: null, quote: [], text: '' };
255  if (v.collapsed[file] === 1) return whole;
256  const at = (l: number) => {
257    const chunk = resident.get(Math.floor(l / m.chunkLines));
258    return chunk && { lines: chunk.lines, i: l % m.chunkLines };
259  };
260  const here = at(line);
261  if (here === undefined) return 'that line is still loading';
262  const kind = here.lines.kind[here.i]!;
263  if (kind === Kind.File) return whole;
264  if (kind !== Kind.Context && kind !== Kind.Add && kind !== Kind.Del)
265    return 'put the cursor on a line of code or a file header';
266  const quote: string[] = [];
267  for (let l = line; l >= Math.max(0, line - QUOTE_BEFORE); l--) {
268    const q = at(l);
269    const k = q?.lines.kind[q.i];
270    if (q === undefined || (k !== Kind.Context && k !== Kind.Add && k !== Kind.Del)) break;
271    quote.unshift(`${k === Kind.Add ? '+' : k === Kind.Del ? '-' : ' '}${q.lines.line(q.i)}`);
272  }
273  const isDel = kind === Kind.Del;
274  return {
275    ...whole,
276    lineNo: isDel ? here.lines.oldNo[here.i]! : here.lines.newNo[here.i]!,
277    side: isDel ? 'old' : 'new',
278    quote,
279  };
280}
281
282/** Sends every comment to Claude as one prompt, then clears them. */
283async function sendComments($: $) {
284  const comments = await read($, commentsAtom);
285  if (comments.length === 0) {
286    $.ui.toast('review: no comments to send; c on a line writes one');
287    return;
288  }
289  const review = await read($, reviewAtom);
290  const sent = await $.prompt.submit({ text: message(review?.title, comments), asUser: true });
291  if (sent.drop !== undefined) {
292    $.ui.toast(`review: comments not sent: ${sent.drop}`);
293    return;
294  }
295  await update($, commentsAtom, () => []);
296  $.ui.toast(`review: sent ${comments.length} comment${comments.length === 1 ? '' : 's'} to Claude`);
297}
298
299/** The command line of a branch review in the session's directory. */
300async function reviewArgv($: $, brv: string, base: string | undefined): Promise<string[]> {
301  const argv = [brv, 'review', '--repo', await $.session.cwd()];
302  return base === undefined ? argv : [...argv, '--base', base];
303}
304
305/**
306 * Runs the open branch review again: brv answers `cached` at once when HEAD has not moved, and writes the new review
307 * when it has. Skipped while a comment is being written, so its text is not lost; the next refresh catches up. No
308 * `--base`: the first run saved it for its branch, and passing it again would save it for whatever branch Claude has
309 * checked out since.
310 */
311async function refresh($: $) {
312  const review = await read($, reviewAtom);
313  if (review === null || review.kind !== 'branch' || (await read($, composingAtom)) !== null) return;
314  if (running) {
315    refreshAgain = true;
316    return;
317  }
318  await run($, await reviewArgv($, brvPath, undefined), { kind: 'branch', base: review.base });
319}
320
321/** Reads brv's progress lines and acts on them; one run at a time, a new one ends the old. */
322async function run($: $, argv: string[], origin: Origin) {
323  stopRun?.();
324  const id = ++runId;
325  running = true;
326  const stream = $.process.spawn({ argv });
327  let isStopped = false;
328  stopRun = () => {
329    isStopped = true;
330    void stream.return?.(undefined as never);
331  };
332  let pending = '';
333  let errors = '';
334  let isAnswered = false;
335  try {
336    for await (const piece of stream) {
337      if (isStopped) break;
338      if (piece.stream === 'stderr') {
339        errors += piece.text;
340        continue;
341      }
342      pending += piece.text;
343      for (let nl = pending.indexOf('\n'); nl >= 0; nl = pending.indexOf('\n')) {
344        const line = pending.slice(0, nl).trimEnd();
345        pending = pending.slice(nl + 1);
346        isAnswered ||= /^(manifest|cached|error) /.test(line);
347        try {
348          await onLine($, line, origin);
349        } catch (err) {
350          $.ui.toast(`review: ${err instanceof Error ? err.message : String(err)}`);
351        }
352      }
353    }
354  } catch (err) {
355    $.ui.toast(`review: could not run ${argv[0]}: ${String(err)}`);
356    return;
357  } finally {
358    if (id === runId) running = false;
359    if (id === runId && refreshAgain) {
360      refreshAgain = false;
361      void refresh($);
362    }
363  }
364  // brv says what went wrong on stdout; a run that ends with nothing said gets its stderr shown instead.
365  if (!isStopped && !isAnswered) {
366    const why = errors.trim().split('\n')[0] ?? '';
367    $.ui.toast(`review: ${argv.slice(0, 2).join(' ')} ended without a review${why === '' ? '' : `: ${why}`}`);
368  }
369}
370
371/** Closes the pane and forgets its review. */
372async function close($: $) {
373  await $.ui.close({ id: PANE });
374  await closed($);
375}
376
377/** Forgets the open review: stops brv and lets go of the chunks. Comments stay, for the review opened again. */
378async function closed($: $) {
379  stopRun?.();
380  stopRun = undefined;
381  relocateTo = undefined;
382  refreshAgain = false;
383  manifest = undefined;
384  viewed = undefined;
385  view = undefined;
386  resident.clear();
387  top = 0;
388  cursor = 0;
389  mounted = undefined;
390  await update($, composingAtom, () => null);
391  await update($, reviewAtom, () => null);
392  $.ui.status(undefined);
393}
394
395/**
396 * Moves comments written on an earlier review of this branch onto `dir`'s review: onto the same line of the same file
397 * when it is still in the diff, else onto that file's header. A comment whose file left the review stays as it was;
398 * it is not drawn, but `s` still sends it.
399 */
400async function relocate($: $, dir: string) {
401  relocateTo = undefined;
402  const m = view?.manifest;
403  const comments = await read($, commentsAtom);
404  if (m === undefined || comments.every((c) => c.dir === dir)) return;
405  const taken = new Set(comments.filter((c) => c.dir === dir).map((c) => c.line));
406  const moved: ReviewComment[] = [];
407  for (const c of comments) {
408    const file = c.dir === dir ? -1 : m.findFile(c.path);
409    if (file < 0) {
410      moved.push(c);
411      continue;
412    }
413    const line = (c.lineNo === null ? undefined : await findLine($, dir, m, file, c)) ?? m.firstLine(file);
414    // Two comments cannot share a line; the later one keeps its old place.
415    if (taken.has(line)) {
416      moved.push(c);
417      continue;
418    }
419    taken.add(line);
420    moved.push({ ...c, dir, line });
421  }
422  await update($, commentsAtom, () => moved);
423  void repaint($);
424}
425
426/** The global line in `file` of `dir`'s review showing `c`'s line on `c`'s side, read from its chunks. */
427async function findLine($: $, dir: string, m: Manifest, file: number, c: ReviewComment): Promise<number | undefined> {
428  const first = m.firstLine(file);
429  const end = first + m.lineCount(file);
430  for (let chunk = Math.floor(first / m.chunkLines); chunk * m.chunkLines < end; chunk++) {
431    const bytes = await readOptional($, `${dir}/${chunkName(chunk, 'lines')}`);
432    if (bytes === undefined) return undefined;
433    const lines = new Lines(bytes);
434    const base = chunk * m.chunkLines;
435    for (let i = Math.max(0, first - base); i < lines.n && base + i < end; i++) {
436      const kind = lines.kind[i];
437      const isOld = kind === Kind.Del || kind === Kind.Context;
438      const isNew = kind === Kind.Add || kind === Kind.Context;
439      const no = c.side === 'old' ? (isOld ? lines.oldNo[i] : 0) : isNew ? lines.newNo[i] : 0;
440      if (no === c.lineNo) return base + i;
441    }
442  }
443  return undefined;
444}
445
446async function onLine($: $, line: string, origin: Origin) {
447  const space = line.indexOf(' ');
448  const word = space < 0 ? line : line.slice(0, space);
449  const rest = space < 0 ? '' : line.slice(space + 1);
450  switch (word) {
451    case 'manifest':
452    case 'cached': {
453      const shown = await read($, reviewAtom);
454      // The same review again (a refresh with HEAD where it was): keep the cursor, the chunks, everything.
455      if (shown?.dir === rest) return;
456      // A refresh landing while a comment is written waits: the comment points into the review on screen.
457      if (shown !== null && (await read($, composingAtom)) !== null) return;
458      const keep = view !== undefined && shown !== null ? view.manifest.file(view.fileOfRow(cursor)).path : undefined;
459      manifest = undefined;
460      top = 0;
461      cursor = 0;
462      await update($, composingAtom, () => null);
463      let m: Manifest;
464      try {
465        m = await loadManifest($, rest);
466      } catch (err) {
467        $.ui.toast(`review: ${err instanceof Error ? err.message : String(err)}`);
468        return;
469      }
470      // Back to the file the cursor was in, when the new review still has it.
471      const file = keep === undefined ? -1 : m.findFile(keep);
472      if (file >= 0 && view !== undefined) cursor = top = view.headerRow(file);
473      const title = `${m.headName} ← ${m.baseName}`;
474      await update($, reviewAtom, () => ({ dir: rest, title, kind: origin.kind, base: origin.base }));
475      // Opened by the person, it takes the keyboard; a refresh only retitles it.
476      await $.ui.open({ id: PANE, title: `Review ${title}`, ...(shown === null ? { focus: true as const } : {}) });
477      relocateTo = rest;
478      if (word === 'cached') await relocate($, rest);
479      void repaint($);
480      return;
481    }
482    case 'done':
483      if (relocateTo !== undefined) await relocate($, relocateTo);
484      return;
485    case 'text':
486    case 'hl': {
487      const index = Number(rest);
488      if (view === undefined || mounted === undefined) return;
489      if (!windowChunks(view, top, mounted.rows - 1).includes(index)) return;
490      if (word === 'hl') resident.delete(index);
491      void repaint($);
492      return;
493    }
494    case 'error':
495      $.ui.toast(`review: ${rest}`);
496      return;
497  }
498}
499
500/** The brv binary: the plugin's `brvPath` option, or brv on PATH. */
501let brvPath = 'brv';
502
503export const register: Register = (on, options) => {
504  brvPath = typeof options.brvPath === 'string' && options.brvPath !== '' ? options.brvPath : 'brv';
505
506  on('session.start', async ($, e, next) => {
507    await $.command.register({
508      name: 'tui-review',
509      description: 'A diff pane of this branch beside the chat, against the branch it is stacked on (or the one given)',
510      argumentHint: '[base | demo]',
511    });
512    return next(e);
513  });
514
515  // The person's close (the frame's close mark, ctrl+x x) takes the review with it. The engine skips this hook for the
516  // plugin's own $.ui.close, so close() cleans up itself.
517  on('ui.close', { id: PANE }, async ($, e, next) => {
518    const result = await next(e);
519    await closed($);
520    return result;
521  });
522
523  // A new conversation (exit, /clear, resume) starts without the last one's review.
524  on('session.end', async ($, e, next) => {
525    await close($);
526    await update($, commentsAtom, () => []);
527    return next(e);
528  });
529
530  on('command.run', { command: 'tui-review' }, async ($, e) => {
531    const arg = e.args.trim();
532    if (arg === 'demo') {
533      void run($, [brvPath, 'gen', '--lines', '100000'], { kind: 'demo' });
534      return { text: 'Opening a 100k-line demo review.' };
535    }
536    const base = arg === '' ? undefined : arg;
537    void run($, await reviewArgv($, brvPath, base), { kind: 'branch', base });
538    return {
539      text:
540        base === undefined
541          ? 'Opening a review of this branch against the branch it is stacked on.'
542          : `Opening a review of this branch against ${base}; brv remembers it for this branch.`,
543    };
544  });
545
546  // Claude never sees the pane: each prompt carries which diff is open and where the cursor is.
547  on('prompt.submit', async ($, e, next) => {
548    const review = await read($, reviewAtom);
549    const m = view?.manifest;
550    if (review === null || review.kind !== 'branch' || m === undefined || m.files === 0) return next(e);
551    const note = reviewContext(m, commentAtCursor(review.dir));
552    return next({ ...e, context: [...(e.context ?? []), note] });
553  });
554
555  // A Bash call may have committed, checked out or rebased: show the branch as it is now.
556  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
557    const result = await next(e);
558    void refresh($);
559    return result;
560  });
561
562  // Coming back to the pane, it shows the branch as it is now.
563  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
564    const result = await next(e);
565    const now = Date.now();
566    if (now - focusRefreshAt >= FOCUS_REFRESH_MS) {
567      focusRefreshAt = now;
568      void refresh($);
569    }
570    return result;
571  });
572
573  on('ui.scroll', { requestId: PANE }, async ($, e) => {
574    scrollTo($, top + e.by);
575    return {};
576  });
577
578  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
579    const review = await read($, reviewAtom);
580    if (e.surface !== 'terminal') {
581      const { Text } = $.ui.resolve(e);
582      return <Text dimColor>The review pane draws in the terminal for now.</Text>;
583    }
584    const { Box, Text, Raster, Button, Input } = $.ui.resolve(e);
585    if (review === null) return <Text dimColor>No review open. Run /tui-review.</Text>;
586    const comments = await read($, commentsAtom);
587    const composing = await read($, composingAtom);
588
589    const cols = Math.max(20, Math.min(512, e.props.bodyColumns));
590    const rows = Math.max(3, Math.min(256, e.props.scroll.bodyRows - 1));
591    mounted = { cols, rows };
592    follow();
593    const drawn = (await frame($)) ?? '';
594    const raster = <Raster key={RASTER} columns={cols} rows={rows} cells={drawn} />;
595
596    if (composing !== null) {
597      return (
598        <Box flexDirection="column">
599          {raster}
600          <Input
601            key="comment"
602            label={`${composing.lineNo === null ? composing.path : `${composing.path}:${composing.lineNo}`} `}
603            placeholder="your comment; Enter saves, empty Enter cancels or deletes"
604            value={composing.text}
605            submitLabel="save"
606            autoFocus
607            onSubmit={(text) => {
608              void (async () => {
609                await update($, commentsAtom, (list) => upsert(list, { ...composing, text }));
610                await update($, composingAtom, () => null);
611              })();
612            }}
613          />
614        </Box>
615      );
616    }
617
618    const key = (hotkey: string, label: string, act: () => void) => (
619      <Button key={hotkey} plain hotkey={hotkey} label={label} onPress={act} />
620    );
621    const startComment = () => {
622      const at = commentAtCursor(review.dir);
623      if (typeof at === 'string') $.ui.toast(`review: ${at}`);
624      // On a line that has a comment already, edit that one.
625      else void update($, composingAtom, () => comments.find((c) => c.dir === at.dir && c.line === at.line) ?? at);
626    };
627    return (
628      <Box flexDirection="column">
629        {raster}
630        <Box flexDirection="row" gap={1}>
631          {key('j', '↓', () => moveCursor($, cursor + 1))}
632          {key('k', '↑', () => moveCursor($, cursor - 1))}
633          {key('n', 'file↓', () => fileJump($, 1))}
634          {key('p', 'file↑', () => fileJump($, -1))}
635          {key('r', 'viewed', () => void toggleViewed($))}
636          {key('c', 'comment', startComment)}
637          {key('s', comments.length === 0 ? 'send' : `send ${comments.length}`, () => void sendComments($))}
638          {key('q', 'close', () => close($))}
639        </Box>
640      </Box>
641    );
642  });
643};
644
hooks/chunks.ts 180 lines
1// Views over brv's cache files (layout: brv/src/format.rs). Nothing is parsed: the bytes `$.fs.read` hands back
2// get typed-array views laid over them, so any line is a direct lookup and no per-line objects are made.
3
4export const VERSION = 1;
5
6export const Kind = { File: 0, Hunk: 1, Context: 2, Add: 3, Del: 4, Note: 5 } as const;
7
8export const FileFlag = { Collapsed: 1, Binary: 2 } as const;
9
10const HEADER = 16;
11const META_U32 = 12;
12const FILE_U32 = 9;
13
14type Bytes = Uint8Array<ArrayBuffer>;
15
16declare global {
17  interface Uint8Array {
18    toBase64(): string;
19  }
20  interface Uint8ArrayConstructor {
21    fromBase64(base64: string): Uint8Array<ArrayBuffer>;
22  }
23}
24
25export const fromBase64 = (base64: string): Bytes => Uint8Array.fromBase64(base64);
26export const toBase64 = (bytes: Uint8Array): string => bytes.toBase64();
27
28const align4 = (n: number) => (n + 3) & ~3;
29
30function header(bytes: Bytes, magic: string): { n: number; m: number } {
31  const tag = String.fromCharCode(bytes[0]!, bytes[1]!, bytes[2]!, bytes[3]!);
32  if (tag !== magic) throw new Error(`not a ${magic} file (${tag})`);
33  const head = new DataView(bytes.buffer, bytes.byteOffset, HEADER);
34  const version = head.getUint16(4, true);
35  if (version !== VERSION) throw new Error(`cache format ${version}, this mod reads ${VERSION}: update brv or the mod`);
36  return { n: head.getUint32(8, true), m: head.getUint32(12, true) };
37}
38
39const u32 = (bytes: Bytes, at: number, count: number) => new Uint32Array(bytes.buffer, bytes.byteOffset + at, count);
40const u16 = (bytes: Bytes, at: number, count: number) => new Uint16Array(bytes.buffer, bytes.byteOffset + at, count);
41const text = (units: Uint16Array, start: number, end: number) => String.fromCharCode(...units.subarray(start, end));
42
43export type FileInfo = {
44  path: string;
45  blob: string;
46  status: string;
47  flags: number;
48  added: number;
49  removed: number;
50  firstLine: number;
51  lineCount: number;
52};
53
54export class Manifest {
55  readonly files: number;
56  readonly totalLines: number;
57  readonly chunkLines: number;
58  readonly baseName: string;
59  readonly headName: string;
60  readonly baseSha: string;
61  readonly headSha: string;
62  readonly mergeBaseSha: string;
63  private readonly records: Uint32Array;
64  private readonly strings: Uint16Array;
65
66  constructor(bytes: Bytes) {
67    const { n, m } = header(bytes, 'BRVM');
68    const meta = u32(bytes, HEADER, META_U32);
69    this.files = n;
70    this.records = u32(bytes, HEADER + META_U32 * 4, n * FILE_U32);
71    this.strings = u16(bytes, HEADER + (META_U32 + n * FILE_U32) * 4, m);
72    this.totalLines = meta[0]!;
73    this.chunkLines = meta[1]!;
74    this.baseName = this.str(meta, 2);
75    this.headName = this.str(meta, 4);
76    this.baseSha = this.str(meta, 6);
77    this.headSha = this.str(meta, 8);
78    this.mergeBaseSha = this.str(meta, 10);
79  }
80
81  private str(from: Uint32Array, at: number): string {
82    const start = from[at]!;
83    return text(this.strings, start, start + from[at + 1]!);
84  }
85
86  file(index: number): FileInfo {
87    const r = this.records.subarray(index * FILE_U32, (index + 1) * FILE_U32);
88    return {
89      path: this.str(r, 0),
90      blob: this.str(r, 2),
91      status: String.fromCharCode(r[4]! & 0xff),
92      flags: r[4]! >>> 8,
93      added: r[5]!,
94      removed: r[6]!,
95      firstLine: r[7]!,
96      lineCount: r[8]!,
97    };
98  }
99
100  /** The file's new blob id, what "viewed" marks are kept under. */
101  blob(index: number): string {
102    return this.str(this.records.subarray(index * FILE_U32, (index + 1) * FILE_U32), 2);
103  }
104
105  firstLine(index: number): number {
106    return this.records[index * FILE_U32 + 7]!;
107  }
108
109  lineCount(index: number): number {
110    return this.records[index * FILE_U32 + 8]!;
111  }
112
113  /** The index of the file at `path`, or -1 when the review has none. */
114  findFile(path: string): number {
115    for (let i = 0; i < this.files; i++) if (this.file(i).path === path) return i;
116    return -1;
117  }
118
119  /** The file a global line belongs to: binary search over the files' first lines. */
120  fileAt(line: number): number {
121    let lo = 0;
122    let hi = this.files - 1;
123    while (lo < hi) {
124      const mid = (lo + hi + 1) >>> 1;
125      if (this.firstLine(mid) <= line) lo = mid;
126      else hi = mid - 1;
127    }
128    return lo;
129  }
130
131  get chunks(): number {
132    return Math.ceil(this.totalLines / this.chunkLines);
133  }
134}
135
136export class Lines {
137  readonly n: number;
138  readonly kind: Uint8Array;
139  readonly oldNo: Uint32Array;
140  readonly newNo: Uint32Array;
141  readonly start: Uint32Array;
142  readonly text: Uint16Array;
143
144  constructor(bytes: Bytes) {
145    const { n, m } = header(bytes, 'BRVL');
146    let at = HEADER;
147    this.n = n;
148    this.kind = bytes.subarray(at, at + n);
149    at += align4(n);
150    this.oldNo = u32(bytes, at, n);
151    at += n * 4;
152    this.newNo = u32(bytes, at, n);
153    at += n * 4;
154    this.start = u32(bytes, at, n + 1);
155    at += (n + 1) * 4;
156    this.text = u16(bytes, at, m);
157  }
158
159  line(i: number): string {
160    return text(this.text, this.start[i]!, this.start[i + 1]!);
161  }
162}
163
164export class Highlights {
165  readonly start: Uint32Array;
166  readonly spans: Uint32Array;
167
168  constructor(bytes: Bytes) {
169    const { n, m } = header(bytes, 'BRVH');
170    this.start = u32(bytes, HEADER, n + 1);
171    this.spans = u32(bytes, HEADER + (n + 1) * 4, m);
172  }
173}
174
175export const spanCol = (s: number) => s & 0x1ff;
176export const spanLen = (s: number) => (s >>> 9) & 0x3ff;
177export const spanStyle = (s: number) => s >>> 19;
178
179export const chunkName = (index: number, ext: 'lines' | 'hl') => `${String(index).padStart(4, '0')}.${ext}`;
180
hooks/comments.ts 49 lines
1// Comments written in the pane, and the one message they are sent to Claude in. Plain data: the hooks module keeps
2// them in $.state and submits the message.
3
4import type { ReviewComment } from '../types';
5
6/** `comments` with the one on `at`'s line replaced by `at`, or dropped when its text is empty. */
7export function upsert(comments: readonly ReviewComment[], at: ReviewComment): ReviewComment[] {
8  const rest = comments.filter((c) => c.dir !== at.dir || c.line !== at.line);
9  if (at.text.trim() === '') return rest;
10  return [...rest, at].sort((a, b) => (a.dir === b.dir ? a.line - b.line : a.dir < b.dir ? -1 : 1));
11}
12
13/** Where a comment points, as `path:line (side)` or `path (whole file)`. */
14export const anchor = (c: ReviewComment) =>
15  c.lineNo === null ? `${c.path} (whole file)` : `${c.path}:${c.lineNo} (${c.side === 'old' ? 'removed line' : 'new'})`;
16
17/** The message the comments go to Claude in: each one's place, the diff lines it is about, and its text. */
18export function message(title: string | undefined, comments: readonly ReviewComment[]): string {
19  const parts = [`Review comments${title === undefined ? '' : ` on ${title}`}:`];
20  for (const c of comments) {
21    const quote = c.quote.length === 0 ? '' : `${fence(c.quote)}diff\n${c.quote.join('\n')}\n${fence(c.quote)}\n`;
22    parts.push(`${anchor(c)}\n${quote}${c.text.trim()}`);
23  }
24  return parts.join('\n\n');
25}
26
27/** A code fence longer than any run of backticks in the quoted lines, so code holding one cannot close it. */
28function fence(lines: readonly string[]): string {
29  let longest = 0;
30  for (const line of lines) for (const run of line.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
31  return '`'.repeat(Math.max(3, longest + 1));
32}
33
34/** Breaks `text` into rows of at most `width` columns, at spaces where it can. */
35export function wrap(text: string, width: number): string[] {
36  const rows: string[] = [];
37  for (const paragraph of text.trim().split('\n')) {
38    let rest = paragraph.trim();
39    while (rest.length > width) {
40      const space = rest.lastIndexOf(' ', width);
41      const cut = space > 0 ? space : width;
42      rows.push(rest.slice(0, cut).trimEnd());
43      rest = rest.slice(cut).trimStart();
44    }
45    rows.push(rest);
46  }
47  return rows;
48}
49
hooks/context.ts 23 lines
1// What Claude is told about the open review with each prompt: which diff, and where the person's cursor is. Claude
2// never sees the pane, so this is how "this line" or "the file I'm on" means something. Plain data, no engine calls.
3
4import type { ReviewComment } from '../types';
5import { anchor } from './comments';
6
7export type ReviewInfo = {
8  headName: string;
9  baseName: string;
10  headSha: string;
11  mergeBaseSha: string;
12};
13
14/** The note added to a prompt while a branch review is open; `at` is the cursor's place, or why there is none. */
15export function reviewContext(review: ReviewInfo, at: ReviewComment | string): string {
16  const range = `${review.mergeBaseSha.slice(0, 12)}..${review.headSha.slice(0, 12)}`;
17  const where = typeof at === 'string' ? '' : ` Their cursor is on ${anchor(at)}.`;
18  return (
19    `The user has the tui-review pane open beside this conversation, reviewing ${review.headName} against ` +
20    `${review.baseName} (git diff ${range}).${where} The pane is not visible to you; read the code with git when needed.`
21  );
22}
23
hooks/paint.ts 213 lines
1// Paints one frame of the review into Raster cells: `[codePoint, fg, bg]` u32 triplets, row-major. Pure and
2// synchronous: the caller loads the chunks the window needs first, so a frame costs only the cells it fills.
3
4import { Kind, type Highlights, type Lines, type Manifest, spanCol, spanLen, spanStyle } from './chunks';
5import { DEFAULT, styleFg, theme } from './theme';
6import type { View } from './view';
7
8export type Loaded = { lines: Lines; hl?: Highlights };
9
10export type Source = {
11  view: View;
12  /** The cursor's row, highlighted. */
13  cursor: number;
14  /** How many files are marked viewed; the marks themselves are the view's collapsed files. */
15  viewed?: { count: number };
16  /** Global lines carrying a comment, marked in the gutter, and how many comments are waiting to be sent. */
17  comments?: { lines: ReadonlySet<number>; count: number };
18  /** A chunk already in memory, or undefined while it is not (drawn as a placeholder, never awaited). */
19  chunk(index: number): Loaded | undefined;
20};
21
22/** Columns before the code: a 6-digit line number, a space, the sign, a space. */
23export const GUTTER = 9;
24const SPACE = 0x20;
25
26class Canvas {
27  constructor(
28    readonly cells: Uint32Array,
29    readonly cols: number,
30  ) {}
31
32  fill(row: number, fg: number, bg: number) {
33    const cells = this.cells;
34    let i = row * this.cols * 3;
35    const end = i + this.cols * 3;
36    for (; i < end; i += 3) {
37      cells[i] = SPACE;
38      cells[i + 1] = fg;
39      cells[i + 2] = bg;
40    }
41  }
42
43  /** Writes code points from `units[start, end)` at `col`, clipped to the row; returns the column after. */
44  units(row: number, col: number, units: Uint16Array, start: number, end: number, fg: number): number {
45    const cells = this.cells;
46    const stop = Math.min(end, start + this.cols - col);
47    let i = (row * this.cols + col) * 3;
48    for (let u = start; u < stop; u++, i += 3) {
49      cells[i] = units[u]!;
50      cells[i + 1] = fg;
51    }
52    return col + Math.max(0, stop - start);
53  }
54
55  text(row: number, col: number, s: string, fg: number): number {
56    const cells = this.cells;
57    const stop = Math.min(s.length, this.cols - col);
58    let i = (row * this.cols + col) * 3;
59    for (let k = 0; k < stop; k++, i += 3) {
60      cells[i] = s.charCodeAt(k);
61      cells[i + 1] = fg;
62    }
63    return col + Math.max(0, stop);
64  }
65
66  /** A line number right-aligned in 6 columns, no string made. */
67  number(row: number, n: number, fg: number) {
68    const cells = this.cells;
69    let col = 5;
70    do {
71      if (col < this.cols) {
72        const i = (row * this.cols + col) * 3;
73        cells[i] = 0x30 + (n % 10);
74        cells[i + 1] = fg;
75      }
76      n = Math.floor(n / 10);
77      col--;
78    } while (n > 0 && col >= 0);
79  }
80
81  background(row: number, bg: number) {
82    const cells = this.cells;
83    const end = (row + 1) * this.cols * 3;
84    for (let i = row * this.cols * 3 + 2; i < end; i += 3) cells[i] = bg;
85  }
86
87  recolor(row: number, from: number, to: number, fg: number) {
88    const cells = this.cells;
89    const stop = Math.min(to, this.cols);
90    for (let col = from, i = (row * this.cols + from) * 3 + 1; col < stop; col++, i += 3) cells[i] = fg;
91  }
92}
93
94/** The header bar's text: the cursor's file and where the cursor is. */
95export function title(src: Pick<Source, 'view' | 'cursor' | 'viewed' | 'comments'>): string {
96  const { view, cursor, viewed, comments } = src;
97  const { manifest } = view;
98  if (manifest.files === 0) return ` ${manifest.headName} ← ${manifest.baseName} │ no changes`;
99  const index = view.fileOfRow(cursor);
100  const file = manifest.file(index);
101  const at = view.rows > 0 ? Math.floor(((cursor + 1) / view.rows) * 100) : 100;
102  const mark = view.collapsed[index] === 1 ? '✓ ' : '';
103  const count = viewed === undefined ? '' : ` │ ✓ ${viewed.count}/${manifest.files} viewed`;
104  const drafts = comments === undefined || comments.count === 0 ? '' : ` │ ✎ ${comments.count}`;
105  return ` ${manifest.headName} ← ${manifest.baseName} │ ${index + 1}/${manifest.files} ${mark}${file.path} +${file.added} −${file.removed} │ ${at}%${count}${drafts}`;
106}
107
108/**
109 * Fills `cells` (cols × rows triplets) with the bar on row 0 and the view from row `top` below it. Returns how many
110 * rows were drawn as placeholders because their chunk was not loaded.
111 */
112export function paint(cells: Uint32Array, cols: number, rows: number, top: number, src: Source): number {
113  const canvas = new Canvas(cells, cols);
114  const { view } = src;
115  canvas.fill(0, theme.barFg, theme.barBg);
116  canvas.text(0, 0, title(src), theme.barFg);
117
118  let missing = 0;
119  for (let row = 1; row < rows; row++) {
120    const at = top + row - 1;
121    if (at >= view.rows) {
122      canvas.fill(row, DEFAULT, DEFAULT);
123      continue;
124    }
125    const { file, line, note } = view.locate(at);
126    if (note > 0) drawNote(canvas, row, view.notes.get(line)?.[note - 1] ?? '', note === 1);
127    else {
128      const markCol = drawRow(canvas, row, file, line, src);
129      if (markCol < 0) missing++;
130      else if (src.comments?.lines.has(line)) canvas.text(row, markCol, '●', theme.commentFg);
131    }
132    if (at === src.cursor) canvas.background(row, theme.cursorBg);
133  }
134  return missing;
135}
136
137/** A row's comment mark goes in the gutter's free column; a file header's in place of its glyph. */
138const MARK = 6;
139const HEADER_MARK = 1;
140
141/** Columns of a note row before its text: the gutter, then the pen on its first row. */
142export const NOTE_TEXT = GUTTER + 2;
143
144/** One row of a note (a comment) under its line: a pen on the first row, the text in the comment colour. */
145function drawNote(canvas: Canvas, row: number, text: string, isFirst: boolean) {
146  canvas.fill(row, theme.commentFg, theme.noteBg);
147  if (isFirst) canvas.text(row, GUTTER, '✎', theme.commentFg);
148  canvas.text(row, NOTE_TEXT, text, theme.noteFg);
149}
150
151/**
152 * Draws `line` of `file` on canvas row `row`. Returns the column a comment mark goes in, or -1 for a placeholder drawn
153 * because the line's chunk is not loaded.
154 */
155function drawRow(canvas: Canvas, row: number, file: number, line: number, src: Source): number {
156  const { view } = src;
157  const { manifest } = view;
158  if (view.collapsed[file] === 1) {
159    const info = manifest.file(file);
160    canvas.fill(row, theme.dimFg, theme.viewedBg);
161    canvas.text(row, 0, ' ✓', theme.viewedFg);
162    const col = canvas.text(row, 3, info.path, theme.dimFg);
163    canvas.text(row, col, `  +${info.added} −${info.removed}`, theme.gutterFg);
164    return HEADER_MARK;
165  }
166  const chunk = src.chunk(Math.floor(line / manifest.chunkLines));
167  if (chunk === undefined) {
168    canvas.fill(row, DEFAULT, DEFAULT);
169    canvas.text(row, GUTTER, '…', theme.dimFg);
170    return -1;
171  }
172  const i = line % manifest.chunkLines;
173  const { lines, hl } = chunk;
174  const kind = lines.kind[i]!;
175  const start = lines.start[i]!;
176  const end = lines.start[i + 1]!;
177
178  switch (kind) {
179    case Kind.File:
180      canvas.fill(row, theme.fileFg, theme.fileBg);
181      canvas.text(row, 0, ' ▍', theme.hunkFg);
182      canvas.units(row, 3, lines.text, start, end, theme.fileFg);
183      return HEADER_MARK;
184    case Kind.Hunk:
185      canvas.fill(row, DEFAULT, DEFAULT);
186      canvas.units(row, 2, lines.text, start, end, theme.hunkFg);
187      return MARK;
188    case Kind.Note:
189      canvas.fill(row, DEFAULT, DEFAULT);
190      canvas.units(row, GUTTER, lines.text, start, end, theme.dimFg);
191      return MARK;
192  }
193
194  const isAdd = kind === Kind.Add;
195  const isDel = kind === Kind.Del;
196  canvas.fill(row, DEFAULT, isAdd ? theme.addBg : isDel ? theme.delBg : DEFAULT);
197  canvas.number(row, isDel ? lines.oldNo[i]! : lines.newNo[i]!, theme.gutterFg);
198  if (isAdd) canvas.text(row, 7, '+', theme.addSign);
199  else if (isDel) canvas.text(row, 7, '-', theme.delSign);
200  canvas.units(row, GUTTER, lines.text, start, end, DEFAULT);
201
202  if (hl !== undefined) {
203    for (let s = hl.start[i]!, last = hl.start[i + 1]!; s < last; s++) {
204      const span = hl.spans[s]!;
205      const fg = styleFg[spanStyle(span)] ?? DEFAULT;
206      if (fg === DEFAULT) continue;
207      const col = GUTTER + spanCol(span);
208      canvas.recolor(row, col, col + spanLen(span), fg);
209    }
210  }
211  return MARK;
212}
213
hooks/view.ts 121 lines
1// The pane's rows over the review's global lines. A collapsed file shows only its header row, and a line with notes
2// (comments) is followed by a row per line of note text, so the rows the pane scrolls through are not the lines: `top`
3// and the cursor count rows, and this maps a row to its file, line and note row.
4
5import type { Manifest } from './chunks';
6
7export class View {
8  /** The row each file's header is on; one more entry holds the row count. */
9  private readonly rowStart: Uint32Array;
10  /** Per file with notes on lines it shows: `[local line, rows, ...]` in line order. */
11  private notesByFile = new Map<number, number[]>();
12  /** What `locate` found, reused so a frame allocates nothing. */
13  readonly at = { file: 0, line: 0, note: 0 };
14
15  constructor(
16    readonly manifest: Manifest,
17    /** 1 per file index shown as its header alone. Shared with the caller: `rebuild()` after changing it. */
18    readonly collapsed: Uint8Array,
19    /** The rows of text drawn under a global line, by line. */
20    public notes: ReadonlyMap<number, readonly string[]> = new Map(),
21  ) {
22    this.rowStart = new Uint32Array(manifest.files + 1);
23    this.rebuild();
24  }
25
26  rebuild() {
27    const { manifest, collapsed, rowStart } = this;
28    const byFile = new Map<number, number[]>();
29    for (const [line, rows] of this.notes) {
30      if (rows.length === 0 || line >= manifest.totalLines) continue;
31      const file = manifest.fileAt(line);
32      const local = line - manifest.firstLine(file);
33      if (local >= this.shown(file)) continue;
34      const list = byFile.get(file) ?? [];
35      list.push(local, rows.length);
36      byFile.set(file, list);
37    }
38    for (const list of byFile.values()) {
39      const pairs: [number, number][] = [];
40      for (let k = 0; k < list.length; k += 2) pairs.push([list[k]!, list[k + 1]!]);
41      pairs.sort((a, b) => a[0] - b[0]);
42      list.length = 0;
43      for (const [local, rows] of pairs) list.push(local, rows);
44    }
45    this.notesByFile = byFile;
46    let row = 0;
47    for (let i = 0; i < manifest.files; i++) {
48      rowStart[i] = row;
49      row += this.shown(i);
50      const list = byFile.get(i);
51      if (list !== undefined) for (let k = 1; k < list.length; k += 2) row += list[k]!;
52    }
53    rowStart[manifest.files] = row;
54  }
55
56  /** Lines a file shows: its header alone when collapsed. */
57  private shown(file: number): number {
58    return this.collapsed[file] === 1 ? 1 : this.manifest.lineCount(file);
59  }
60
61  get rows(): number {
62    return this.rowStart[this.manifest.files]!;
63  }
64
65  headerRow(file: number): number {
66    return this.rowStart[file]!;
67  }
68
69  /** The file a row belongs to: binary search over the files' header rows. */
70  fileOfRow(row: number): number {
71    let lo = 0;
72    let hi = this.manifest.files - 1;
73    while (lo < hi) {
74      const mid = (lo + hi + 1) >>> 1;
75      if (this.rowStart[mid]! <= row) lo = mid;
76      else hi = mid - 1;
77    }
78    return lo;
79  }
80
81  /** A row's file, global line, and note row: 0 for the line itself, 1 and on for the rows of its notes. */
82  locate(row: number): View['at'] {
83    const file = this.fileOfRow(row);
84    const first = this.manifest.firstLine(file);
85    let offset = row - this.rowStart[file]!;
86    const at = this.at;
87    at.file = file;
88    at.note = 0;
89    const list = this.notesByFile.get(file);
90    if (list !== undefined) {
91      for (let k = 0; k < list.length; k += 2) {
92        const local = list[k]!;
93        if (offset <= local) break;
94        if (offset <= local + list[k + 1]!) {
95          at.line = first + local;
96          at.note = offset - local;
97          return at;
98        }
99        offset -= list[k + 1]!;
100      }
101    }
102    at.line = first + offset;
103    return at;
104  }
105
106  /** The global line a row shows, or whose notes it shows; a collapsed file's header row is its header line. */
107  lineOfRow(row: number): number {
108    return this.locate(row).line;
109  }
110
111  /** The row a global line is on; a line inside a collapsed file is on its header row. */
112  rowOfLine(line: number): number {
113    const file = this.manifest.fileAt(line);
114    const local = this.collapsed[file] === 1 ? 0 : line - this.manifest.firstLine(file);
115    let row = this.rowStart[file]! + local;
116    const list = this.notesByFile.get(file);
117    if (list !== undefined) for (let k = 0; k < list.length && list[k]! < local; k += 2) row += list[k + 1]!;
118    return row;
119  }
120}
121
hooks/viewed.ts 47 lines
1// "Viewed" marks, kept across sessions in $.store under one key, by each file's new blob id: a file whose content a
2// rebase leaves alone keeps its mark, and one that changed comes back unviewed. Plain data: the hooks module does the
3// $.store calls.
4
5export const VIEWED_KEY = 'viewed';
6/** Marks older than this are dropped on the next write, and at most MAX are kept, so the store stays small. */
7const KEEP_DAYS = 90;
8const MAX = 40_000;
9
10const today = () => Math.floor(Date.now() / 86_400_000);
11
12export class Viewed {
13  private constructor(private readonly marks: Map<string, number>) {}
14
15  /** From what `$.store.get(VIEWED_KEY)` gave back; anything malformed counts as no marks. */
16  static from(stored: unknown): Viewed {
17    const entries =
18      typeof stored === 'object' && stored !== null
19        ? Object.entries(stored).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
20        : [];
21    return new Viewed(new Map(entries));
22  }
23
24  has(blob: string): boolean {
25    return this.marks.has(blob);
26  }
27
28  /** Flips the mark; returns whether the file is viewed now. Save with `stored()` afterwards. */
29  toggle(blob: string): boolean {
30    const isViewed = !this.marks.delete(blob);
31    if (isViewed) this.marks.set(blob, today());
32    const oldest = today() - KEEP_DAYS;
33    for (const [key, day] of this.marks) if (day < oldest) this.marks.delete(key);
34    // Insertion order is age order (a re-mark is a new entry), so the first keys are the oldest.
35    for (const key of this.marks.keys()) {
36      if (this.marks.size <= MAX) break;
37      this.marks.delete(key);
38    }
39    return isViewed;
40  }
41
42  /** What to `$.store.set(VIEWED_KEY, …)`. */
43  stored(): Record<string, number> {
44    return Object.fromEntries(this.marks);
45  }
46}
47
hooks/theme.ts 43 lines
1// Colours, 0xRRGGBB, or DEFAULT for the terminal's own. Style ids follow brv/src/styles.rs; keep the order.
2
3export const DEFAULT = 0x01000000;
4
5export const styleFg: readonly number[] = [
6  DEFAULT, // plain
7  0xc678dd, // keyword
8  0x98c379, // string
9  0x7f848e, // comment
10  0x61afef, // function
11  0xe5c07b, // type
12  0xd19a66, // number
13  0xd19a66, // constant
14  0x56b6c2, // operator
15  0xabb2bf, // punctuation
16  DEFAULT, // variable
17  0xe06c75, // property
18  0xd19a66, // attribute
19  0xe06c75, // tag
20  0x56b6c2, // builtin
21  0x56b6c2, // escape
22];
23
24export const theme = {
25  barFg: 0xdcdfe4,
26  barBg: 0x2c313c,
27  fileFg: 0xffffff,
28  fileBg: 0x3b4252,
29  viewedFg: 0x98c379,
30  viewedBg: 0x2a2f38,
31  hunkFg: 0x56b6c2,
32  gutterFg: 0x636d83,
33  addBg: 0x1e3a2a,
34  delBg: 0x3f2226,
35  addSign: 0x98c379,
36  delSign: 0xe06c75,
37  dimFg: 0x7f848e,
38  cursorBg: 0x3e4451,
39  commentFg: 0xe5c07b,
40  noteFg: 0xdcdfe4,
41  noteBg: 0x2f2b1f,
42} as const;
43
types/index.d.ts 31 lines
1/**
2 * The review the pane shows: brv's cache directory, and the title naming head and base. A `branch` review is run again
3 * when HEAD may have moved, with the `base` it was opened with; a `demo` one never is.
4 */
5export type TuiReviewOpen = { dir: string; title: string; kind: 'branch' | 'demo'; base?: string };
6
7/** A comment written in the pane, waiting to be sent to Claude. */
8export type ReviewComment = {
9  /** The review it was written in, and the global line there it is on. */
10  dir: string;
11  line: number;
12  path: string;
13  /** The commented line's number in the file, on its side of the diff; null for a comment on the whole file. */
14  lineNo: number | null;
15  side: 'old' | 'new' | null;
16  /** The diff lines the comment is about, each with its sign, the commented line last. */
17  quote: string[];
18  text: string;
19};
20
21declare module 'claude-code' {
22  interface PluginState {
23    'tui-review': {
24      review: TuiReviewOpen | null;
25      comments: ReviewComment[];
26      /** The comment the pane is asking the text of, or null. */
27      composing: ReviewComment | null;
28    };
29  }
30}
31