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

/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.
/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
/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 / k | cursor down / up; the window follows it |
| mouse wheel | scroll the window; the cursor stays in it |
n / p | cursor to the next / previous file (p inside a file goes to its own header first) |
r | mark the cursor's file viewed and collapse it to its header; on a viewed file, clear the mark and open it |
c | comment on the cursor's line, or on the whole file from its header; on a commented line, edit the comment |
s | send every comment to Claude as one message, then clear them |
q | close 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.
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 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.
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.
hooks/register.tsx 644 lines1import { 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};
644hooks/chunks.ts 180 lines1// 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}`;
180hooks/comments.ts 49 lines1// 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}
49hooks/context.ts 23 lines1// 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}
23hooks/paint.ts 213 lines1// 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}
213hooks/view.ts 121 lines1// 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}
121hooks/viewed.ts 47 lines1// "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}
47hooks/theme.ts 43 lines1// 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;
43types/index.d.ts 31 lines1/**
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