Shows the proof behind a PR under the reply that names it: checks, before/after screenshots, what was not checked, with Open and Reveal buttons

Shows what an agent verified on a PR, under the reply that names it. When Claude writes "shop-web#541 is ready for review", pr-proof adds ▸ Proof. The same button goes under Claude Code's own "Created PR #541" line, found from the URL gh pr create printed. Press it to see:
Pictures are sharp in kitty and Ghostty and drawn as colored blocks in other terminals (and inside tmux, Zellij or Herdr). Both need ImageMagick; without it, the buttons still open and reveal the files.
pr-proof reads <root>/<repo>/<pr>/proof.json and the files beside it, <root> being the root setting (~/pr-proof by default). It names that folder in the system prompt, so Claude writes records where the mod reads them. A reply finds a record by repo#541, owner/repo#541, an alias the record lists, the PR's GitHub URL, or a bare #541 (when one record has that number, or inside that repo). It draws only the session's own records: one whose session is this session's id (the system prompt gives it), one a call in it wrote or named by path, a PR it opened or edited with gh pr, or a PR you named in a prompt. Another session's PR, mentioned in passing, draws nothing. Of a PR's mentions, only the latest in the transcript keeps the button.
The plugin ships the record-proof skill, which tells Claude how to write the record and outline its screenshots while it verifies. The record's shape is in skills/record-proof/SKILL.md.
A PR opened with gh pr create and no record yet gets one line in the model's context saying so, so the record is written while the checks are fresh.
bin/publish-pictures.sh <owner/repo> <pr> <picture>... puts a record's pictures on the repo's orphan pr-proof branch and prints the before/after table for the PR's description.
/proof lists the records, this session's own first, then the others; /proof repo#541 reveals one's folder.
root: where records live (default ~/pr-proof). Set it in /config, or in settings as pluginConfigs.pr-proof.root.preview: auto, image or blocks.hooks/register.tsx 836 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, PromptOrigin, Register, RenderElement, RenderInput } from "claude-code";
3
4import type { Preview } from "../types";
5import { decodePreview, fromBase64 } from "./preview";
6import {
7 countsOf,
8 findRecordPaths,
9 findRefs,
10 findUrlRefs,
11 GH_PR_CHANGE,
12 ghPrText,
13 labelOf,
14 outputText,
15 parseRecord,
16 parseRefKey,
17 refKey,
18 resolveRefs,
19 type ProofRecord,
20 type Ref,
21 type Result,
22} from "./record";
23
24/** A picture's width in cells: the narrowest, the widest, and its height box per cell across (blocks are 2 pixels tall). */
25const MIN_THUMB_WIDTH = 44;
26const MAX_THUMB_WIDTH = 80;
27const THUMB_ASPECT = 28 / 44;
28
29/**
30 * As wide as two pictures side by side fit the transcript (a change's before
31 * and after), between the narrowest and the widest: 8 cells go to the two
32 * indents and the gap between them.
33 */
34function thumbWidthFor(columns: number | undefined): number {
35 if (!columns) return MIN_THUMB_WIDTH;
36 return Math.max(MIN_THUMB_WIDTH, Math.min(MAX_THUMB_WIDTH, Math.floor((columns - 8) / 2)));
37}
38/** How long a scan of the records folder is reused before it is read again. */
39const INDEX_TTL_MS = 10_000;
40/** How many records one reply shows a button for. */
41const MAX_PER_REPLY = 5;
42/** The prompts the person sent: typed, from Remote Control, or pinged from Slack. */
43const PERSON = new Set<PromptOrigin["kind"]>(["composer", "bridge", "slack-ping"]);
44
45const open = atom({ plugin: "pr-proof", key: "open" } as const, []);
46const seen = atom({ plugin: "pr-proof", key: "seen" } as const, []);
47
48/**
49 * `blocks` prints `<w> <h>` then a half-block-sized PPM as base64; `image`
50 * prints the size then a PNG of at most 960×600 as base64. Exits non-zero
51 * when ImageMagick is missing.
52 *
53 * "$1" is always an absolute path and "$4" the format its extension names,
54 * forced on ImageMagick ("png:/path") so no prefix or content-sniffed coder runs.
55 */
56const THUMB_SCRIPT = `
57f="$1"; w="$2"; h="$3"; t="$4"; mode="$5"
58case "$f" in /*) ;; *) exit 2 ;; esac
59# Windows' own convert.exe (FAT to NTFS) is on a WSL PATH: not ImageMagick.
60has() { p=$(command -v "$1" 2>/dev/null) && case "$p" in /mnt/*) false ;; esac; }
61if has magick; then id() { magick identify "$@"; }; cv() { magick "$@"; }
62elif has convert && has identify; then id() { identify "$@"; }; cv() { convert "$@"; }
63else exit 3; fi
64id -format "%w %h\\n" "$t:$f[0]" 2>/dev/null | head -n 1
65if [ "$mode" = image ]; then
66 cv "$t:$f[0]" -auto-orient -thumbnail "960x600>" png:- | base64 | tr -d '\\n'
67else
68 cv "$t:$f[0]" -auto-orient -thumbnail "\${w}x\${h}" -background "#000000" -alpha remove -alpha off -depth 8 ppm:- | base64 | tr -d '\\n'
69fi
70`;
71
72type Os = "wsl" | "mac" | "linux" | "windows";
73type Mode = "image" | "blocks";
74type Thumb = {
75 width?: number;
76 height?: number;
77 preview?: Preview;
78 png?: string;
79};
80
81const FORMATS: Record<string, string> = {
82 png: "png",
83 jpg: "jpeg",
84 jpeg: "jpeg",
85 gif: "gif",
86 webp: "webp",
87 bmp: "bmp",
88};
89
90/** The PNG signature, as base64 begins it. */
91const PNG_BASE64 = "iVBORw0KGgo";
92
93const dirname = (path: string) => path.replace(/\/[^/]*$/, "") || "/";
94
95let os: Promise<Os> | undefined;
96
97async function detectOs($: EngineInterface): Promise<Os> {
98 if (await $.env.get("WSL_DISTRO_NAME")) return "wsl";
99 if ((await $.env.get("OS")) === "Windows_NT") return "windows";
100 try {
101 const { stdout } = await $.process.run(["uname", "-s"]);
102 return stdout.includes("Darwin") ? "mac" : "linux";
103 } catch {
104 return "linux";
105 }
106}
107
108let graphics: Promise<boolean> | undefined;
109
110async function detectGraphics($: EngineInterface): Promise<boolean> {
111 // A multiplexer between Claude Code and the terminal drops the protocol.
112 const multiplexed =
113 (await $.env.get("TMUX")) ||
114 (await $.env.get("STY")) ||
115 (await $.env.get("ZELLIJ")) ||
116 (await $.env.get("HERDR_ENV"));
117 if (multiplexed) return false;
118 const program = ((await $.env.get("TERM_PROGRAM")) ?? "").toLowerCase();
119 const term = (await $.env.get("TERM")) ?? "";
120 return (
121 Boolean(await $.env.get("KITTY_WINDOW_ID")) ||
122 program === "ghostty" ||
123 /kitty|ghostty/.test(term)
124 );
125}
126
127async function modeFor($: EngineInterface, setting: string): Promise<Mode> {
128 if (setting === "image" || setting === "blocks") return setting;
129 graphics ??= detectGraphics($);
130 return (await graphics) ? "image" : "blocks";
131}
132
133async function windowsPath($: EngineInterface, path: string) {
134 return (await $.process.run(["wslpath", "-w", path])).stdout.trim();
135}
136
137/** Shows the path selected in Explorer (WSL, Windows) or Finder (macOS). */
138async function reveal($: EngineInterface, path: string): Promise<string> {
139 os ??= detectOs($);
140 switch (await os) {
141 case "wsl":
142 // explorer.exe exits 1 even when it worked.
143 await $.process.run(["explorer.exe", `/select,${await windowsPath($, path)}`]);
144 return "Explorer";
145 case "windows":
146 await $.process.run(["explorer.exe", `/select,${path}`]);
147 return "Explorer";
148 case "mac":
149 await $.process.run(["open", "-R", path]);
150 return "Finder";
151 case "linux": {
152 const shown = await $.process
153 .run([
154 "dbus-send",
155 "--session",
156 "--dest=org.freedesktop.FileManager1",
157 "--type=method_call",
158 "/org/freedesktop/FileManager1",
159 "org.freedesktop.FileManager1.ShowItems",
160 `array:string:file://${encodeURI(path)}`,
161 "string:",
162 ])
163 .catch(() => ({ exitCode: 1 }));
164 if (shown.exitCode !== 0) await $.process.run(["xdg-open", dirname(path)]);
165 return "the file manager";
166 }
167 }
168}
169
170/** Opens the file in the app the desktop associates with it. */
171async function openFile($: EngineInterface, path: string): Promise<void> {
172 os ??= detectOs($);
173 switch (await os) {
174 case "wsl":
175 await $.process.run(["explorer.exe", await windowsPath($, path)]);
176 return;
177 case "windows":
178 // Never cmd.exe: it parses the path again, so `&` in a folder name runs.
179 await $.process.run(["explorer.exe", path]);
180 return;
181 case "mac":
182 await $.process.run(["open", path]);
183 return;
184 case "linux":
185 await $.process.run(["xdg-open", path]);
186 return;
187 }
188}
189
190/** The file's own extension, from its name alone. */
191const extensionOf = (path: string) => {
192 const name = path.split("/").at(-1) ?? "";
193 return name.includes(".") ? (name.split(".").at(-1)?.toLowerCase() ?? "") : "";
194};
195
196const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
197
198/**
199 * What Open hands to the desktop's default app, and the bytes each must start
200 * with: `[offset, bytes]` pairs that all have to match. A record is only data,
201 * and an executable (`.command`, `.bat`, `.desktop`, `.app`) would run when
202 * opened, so anything else is revealed instead.
203 */
204const PICTURES: Record<string, [number, number[]][][]> = {
205 png: [[[0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]]]],
206 jpg: [[[0, [0xff, 0xd8, 0xff]]]],
207 jpeg: [[[0, [0xff, 0xd8, 0xff]]]],
208 gif: [[[0, ascii("GIF87a")]], [[0, ascii("GIF89a")]]],
209 webp: [[[0, ascii("RIFF")], [8, ascii("WEBP")]]],
210 bmp: [[[0, ascii("BM")]]],
211 mp4: [[[4, ascii("ftyp")]]],
212 mov: [[[4, ascii("ftyp")]]],
213 webm: [[[0, [0x1a, 0x45, 0xdf, 0xa3]]]],
214};
215const TEXT = new Set(["json", "txt", "md", "log", "csv"]);
216
217/** Whether the bytes are what this extension says: its own signature, or plain text. */
218function looksLike(extension: string, bytes: Uint8Array): boolean {
219 if (TEXT.has(extension)) {
220 const head = bytes.subarray(0, 4096);
221 // A script or a desktop entry renamed to .txt is not text to open.
222 const start = new TextDecoder().decode(head.subarray(0, 64));
223 return !head.includes(0) && !start.startsWith("#!") && !start.includes("[Desktop Entry]");
224 }
225 const forms = PICTURES[extension] ?? [];
226 return forms.some((form) => form.every(([at, sig]) => sig.every((b, i) => bytes[at + i] === b)));
227}
228
229/**
230 * The real path to open, or undefined to reveal instead. Open takes only a
231 * regular file, not a link, that resolves inside the record's own folder (no
232 * linked folder above it leads out) under a viewable extension; on Linux,
233 * where xdg-open can go by content, its bytes must match that extension too.
234 * The opener gets the resolved path that was checked, never the given one.
235 */
236async function openable($: EngineInterface, path: string, within: string): Promise<string | undefined> {
237 const extension = extensionOf(path);
238 if (!TEXT.has(extension) && !PICTURES[extension]) return undefined;
239 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined);
240 const home = await $.fs.stat(within, { resolve: true }).catch(() => undefined);
241 const real = stat?.realPath;
242 const root = home?.realPath;
243 if (!stat || stat.kind !== "file" || stat.isLink || !real || !root) return undefined;
244 if (!real.startsWith(`${root.replace(/\/+$/, "")}/`) || extensionOf(real) !== extension) return undefined;
245 os ??= detectOs($);
246 if ((await os) !== "linux") return real;
247 const read = await $.fs.read(real, { as: "bytes" }).catch(() => undefined);
248 return read && looksLike(extension, fromBase64(read.base64)) ? real : undefined;
249}
250
251/** Opens a record's file (when it is safe to) or reveals it, and says which. */
252async function act(
253 $: EngineInterface,
254 asked: "open" | "reveal",
255 path: string,
256 within: string,
257): Promise<string> {
258 const name = path.split("/").at(-1) ?? path;
259 const real = asked === "open" ? await openable($, path, within) : undefined;
260 const verb = real ? "open" : "reveal";
261 try {
262 if (real) {
263 await openFile($, real);
264 $.ui.toast(`Opened ${name}`);
265 return `Opened ${real}.`;
266 }
267 const app = await reveal($, path);
268 $.ui.toast(`Revealed ${name} in ${app}`);
269 return `Revealed ${path} in ${app}.`;
270 } catch (error) {
271 const why = error instanceof Error ? error.message : String(error);
272 $.ui.toast(`Could not ${verb} ${name}: ${why}`);
273 return `Could not ${verb} ${path}: ${why}`;
274 }
275}
276
277const thumbs = new Map<string, Promise<Thumb>>();
278
279async function makeThumb($: EngineInterface, path: string, mode: Mode, width: number): Promise<Thumb> {
280 const format = FORMATS[path.split(".").at(-1)?.toLowerCase() ?? ""];
281 if (!path.startsWith("/") || !format) return {};
282 try {
283 const { exitCode, stdout } = await $.process.run(
284 ["sh", "-c", THUMB_SCRIPT, "sh", path, String(width), String(Math.round(width * THUMB_ASPECT)), format, mode],
285 { timeoutMs: 15_000 },
286 );
287 if (exitCode !== 0) return {};
288 const [size = "", picture = ""] = stdout.split("\n");
289 const [sourceWidth, sourceHeight] = size.trim().split(/[ x]/).map(Number);
290 const data = picture.trim();
291 return {
292 width: sourceWidth || undefined,
293 height: sourceHeight || undefined,
294 // A non-PNG source makes the engine refuse the whole reply's drawing.
295 ...(data && mode === "image" && data.startsWith(PNG_BASE64) ? { png: data } : {}),
296 ...(data && mode === "blocks" ? { preview: decodePreview(fromBase64(data)) } : {}),
297 };
298 } catch {
299 return {};
300 }
301}
302
303function thumb($: EngineInterface, path: string, mode: Mode, width: number): Promise<Thumb> {
304 const key = `${mode}:${width}:${path}`;
305 let made = thumbs.get(key);
306 if (!made) {
307 made = makeThumb($, path, mode, width);
308 thumbs.set(key, made);
309 }
310 return made;
311}
312
313/**
314 * Each row that names a PR, numbered the first time it is drawn naming one.
315 * Rows draw as the transcript adds them, so this is the transcript's order: a
316 * row drawn again later (at a scroll's edge, or once its record landed) keeps
317 * the place it had.
318 */
319const places = new Map<string, number>();
320
321function placeOf(instance: string): number {
322 let place = places.get(instance);
323 if (place === undefined) {
324 place = places.size;
325 places.set(instance, place);
326 }
327 return place;
328}
329
330/**
331 * Each record's mentions, in transcript order (placeOf): a reply, or a `gh pr`
332 * row. Only the newest draws the button, so one PR shows one. A module
333 * variable, not `$.state`: a render hook cannot write, and a reload draws
334 * every row again, which rebuilds it in the same order.
335 */
336const mentions = new Map<string, string[]>();
337
338/** Whether `instance` is the record's newest mention, and whether it just took the button from an older one. */
339function newestMention(dir: string, instance: string): { isNewest: boolean; displaced: boolean } {
340 const list = mentions.get(dir) ?? [];
341 if (list.includes(instance)) return { isNewest: list.at(-1) === instance, displaced: false };
342 // An older row that finds the record only now (it was drawn before the record was written) goes in its place, not last.
343 const next = [...list, instance].sort((a, b) => placeOf(a) - placeOf(b)).slice(-200);
344 mentions.set(dir, next);
345 const isNewest = next.at(-1) === instance;
346 return { isNewest, displaced: isNewest && list.length > 0 };
347}
348
349/** How long an open PR's state is trusted before `gh` is asked again; a merged one is final. */
350const OPEN_STATE_TTL_MS = 5 * 60_000;
351/** How long a drawing waits on `gh` before drawing the button and checking behind it. */
352const STATE_WAIT_MS = 1_500;
353
354/** Where a PR stands: open (worth reviewing), done (merged or closed), or no PR `gh` can find. */
355type PrState = "open" | "done" | "unknown";
356
357const prStates = new Map<string, { at: number; state: Promise<PrState> }>();
358
359async function askState($: EngineInterface, slug: string, pr: number): Promise<PrState> {
360 if (!/^[\w.-]+\/[\w.-]+$/.test(slug)) return "unknown";
361 try {
362 const { exitCode, stdout } = await $.process.run(
363 ["gh", "api", `repos/${slug}/pulls/${pr}`, "--jq", ".state"],
364 { timeoutMs: 10_000 },
365 );
366 if (exitCode !== 0) return "unknown";
367 return stdout.trim() === "open" ? "open" : "done";
368 } catch {
369 return "unknown";
370 }
371}
372
373/**
374 * The PR's state if `gh` answers within STATE_WAIT_MS, else `whileWaiting`
375 * and a redraw once it answers. A done PR stays done, so its answer is kept
376 * for the session; any other is asked again after OPEN_STATE_TTL_MS.
377 */
378async function prState($: EngineInterface, slug: string, pr: number, whileWaiting: PrState): Promise<PrState> {
379 const key = `${slug}#${pr}`;
380 const now = await $.clock.now();
381 let known = prStates.get(key);
382 if (!known || now - known.at > OPEN_STATE_TTL_MS) {
383 const state = askState($, slug, pr);
384 known = { at: now, state };
385 prStates.set(key, known);
386 void state.then((st) => {
387 if (st === "done") prStates.set(key, { at: Number.MAX_SAFE_INTEGER, state });
388 });
389 }
390 const late = Symbol("late");
391 const wait = new AbortController();
392 const answer = await Promise.race([
393 known.state,
394 new Promise<typeof late>((resolve) =>
395 void $.clock.sleep(STATE_WAIT_MS, { signal: wait.signal }).then(
396 () => resolve(late),
397 () => undefined,
398 ),
399 ),
400 ]);
401 wait.abort();
402 if (answer !== late) return answer;
403 void known.state.then((st) => st !== whileWaiting && $.ui.invalidate("ui.render"));
404 return whileWaiting;
405}
406
407let index: { at: number; root: string; records: Promise<ProofRecord[]> } | undefined;
408
409async function scan($: EngineInterface, root: string): Promise<ProofRecord[]> {
410 const records: ProofRecord[] = [];
411 const repos = await $.fs.list(root).catch(() => []);
412 for (const repo of repos) {
413 // The folder name ends up in paths handed to the OS: plain names only.
414 if (repo.kind !== "dir" || !/^[\w][\w.-]*$/.test(repo.name)) continue;
415 const prs = await $.fs.list(`${root}/${repo.name}`).catch(() => []);
416 for (const pr of prs) {
417 if (pr.kind !== "dir" || !/^\d+$/.test(pr.name)) continue;
418 const dir = `${root}/${repo.name}/${pr.name}`;
419 const text = await $.fs.read(`${dir}/proof.json`).catch(() => undefined);
420 const record = text && parseRecord(text, dir, repo.name, Number(pr.name));
421 if (record) records.push(record);
422 }
423 }
424 return records;
425}
426
427async function recordsIn($: EngineInterface, root: string, fresh = false) {
428 const now = await $.clock.now();
429 if (fresh || !index || index.root !== root || now - index.at > INDEX_TTL_MS) {
430 index = { at: now, root, records: scan($, root) };
431 }
432 return index.records;
433}
434
435let here: Promise<string | undefined> | undefined;
436
437/** The name of the repo the session runs in, for a bare `#541`. */
438async function repoHere($: EngineInterface): Promise<string | undefined> {
439 here ??= $.process
440 .run(["git", "rev-parse", "--show-toplevel"])
441 .then((r) => (r.exitCode === 0 ? r.stdout.trim().split("/").at(-1) : undefined))
442 .catch(() => undefined);
443 return here;
444}
445
446/**
447 * The records that are this session's to show, the ones it touched first: a
448 * PR it opened or changed with `gh pr`, a record whose folder a call named, a
449 * PR the person named in a prompt (all kept in `seen`), then a record whose
450 * `session` is this one. `also` adds PRs the row itself shows are this
451 * session's. Another session's record, mentioned in passing, is not among them.
452 */
453async function ownRecords(
454 $: EngineInterface,
455 records: ProofRecord[],
456 here?: string,
457 also: Ref[] = [],
458): Promise<ProofRecord[]> {
459 const refs = (await read($, seen)).map(parseRefKey).filter((r): r is Ref => !!r);
460 const touched = resolveRefs([...also, ...refs], records, here);
461 const id = await $.session.id().catch(() => undefined);
462 return [...touched, ...records.filter((r) => id && r.session === id && !touched.includes(r))];
463}
464
465/** Adds PRs to the session's own, writing only when one is new so the rows reading them redraw only then. */
466async function addSeen($: EngineInterface, refs: Ref[]): Promise<void> {
467 const list = await read($, seen);
468 if (refs.every((r) => list.includes(refKey(r)))) return;
469 await update($, seen, (now) => {
470 const keys = [...new Set(refs.map(refKey))].filter((k) => !now.includes(k));
471 return keys.length ? [...now, ...keys].slice(-100) : now;
472 });
473}
474
475async function rootOf($: EngineInterface, setting: string): Promise<string> {
476 const home = (await $.env.get("HOME")) ?? "";
477 const root = setting.replace(/^~(?=$|\/)/, home).replace(/\/+$/, "");
478 return root || `${home}/pr-proof`;
479}
480
481const MARK: Record<Result, { glyph: string; color: string }> = {
482 pass: { glyph: "✓", color: "success" },
483 fail: { glyph: "✗", color: "error" },
484 skip: { glyph: "!", color: "warning" },
485};
486
487/** One picture: sharp on kitty or Ghostty, blocks elsewhere, its name and buttons. */
488async function picture(
489 $: EngineInterface,
490 e: RenderInput,
491 key: string,
492 label: string,
493 path: string,
494 within: string,
495 mode: Mode,
496 width: number,
497) {
498 const { Box, Text, Button } = $.ui.resolve(e);
499 const terminal = e.surface === "terminal" ? $.ui.resolve(e) : undefined;
500 const Image = terminal?.Image;
501 const Raster = terminal?.Raster;
502 const t: Thumb = terminal ? await thumb($, path, mode, width) : {};
503 const rows =
504 t.width && t.height
505 ? Math.max(1, Math.min(Math.round(width * THUMB_ASPECT), Math.round((width * t.height) / t.width / 2)))
506 : 0;
507 return (
508 <Box key={`pic-${key}`} flexDirection="column" width={width}>
509 {Image && t.png && rows > 0 && (
510 <Image
511 key={`img-${key}`}
512 source={{ png: t.png }}
513 columns={width}
514 rows={rows}
515 alt={label}
516 />
517 )}
518 {Raster && !t.png && t.preview && (
519 <Raster
520 key={`raster-${key}`}
521 columns={t.preview.columns}
522 rows={t.preview.rows}
523 cells={t.preview.cells}
524 />
525 )}
526 <Box flexDirection="row" gap={1}>
527 <Text dimColor>{label}</Text>
528 <Button key={`open-${key}`} label="Open" onPress={() => void act($, "open", path, within)} />
529 <Button key={`reveal-${key}`} label="Reveal" onPress={() => void act($, "reveal", path, within)} />
530 </Box>
531 </Box>
532 );
533}
534
535async function details(
536 $: EngineInterface,
537 e: RenderInput,
538 r: ProofRecord,
539 id: string,
540 mode: Mode,
541 hide: () => void,
542) {
543 const { Box, Text, Button, Link } = $.ui.resolve(e);
544 const counts = { pass: 0, fail: 0, skip: r.notChecked.length };
545 for (const c of r.checks) counts[c.result]++;
546
547 const width = thumbWidthFor(e.viewport?.columns);
548 const changes = [];
549 for (const [i, c] of r.changes.entries()) {
550 const sides = [];
551 if (c.before)
552 sides.push(await picture($, e, `${id}-c${i}-before`, "Before", `${r.dir}/${c.before}`, r.dir, mode, width));
553 else sides.push(<Text key={`${id}-c${i}-nobefore`} dimColor>(no before shot)</Text>);
554 if (c.after)
555 sides.push(await picture($, e, `${id}-c${i}-after`, "After", `${r.dir}/${c.after}`, r.dir, mode, width));
556 changes.push(
557 <Box key={`${id}-c${i}`} flexDirection="column">
558 <Text bold>{c.title}</Text>
559 <Box flexDirection="row" gap={2} flexWrap="wrap">
560 {sides}
561 </Box>
562 </Box>,
563 );
564 }
565
566 return (
567 <Box key={`${id}-details`} flexDirection="column" gap={1} paddingLeft={2}>
568 <Box flexDirection="row" gap={2}>
569 <Text color="success">✓ {counts.pass} passed</Text>
570 {counts.fail > 0 && <Text color="error">✗ {counts.fail} failed</Text>}
571 {counts.skip > 0 && <Text color="warning">! {counts.skip} not checked</Text>}
572 </Box>
573 {changes.length > 0 && <Box flexDirection="column" gap={1}>{changes}</Box>}
574 {r.checks.length > 0 && (
575 <Box flexDirection="column">
576 <Text dimColor>Checks</Text>
577 {r.checks.map((c, i) => (
578 <Box key={`${id}-k${i}`} flexDirection="row" gap={1} flexWrap="wrap">
579 <Text color={MARK[c.result].color}>{MARK[c.result].glyph}</Text>
580 <Text>{c.claim}</Text>
581 {c.where && <Text dimColor>· {c.where}</Text>}
582 {c.evidence.map((name, j) => (
583 <Button
584 key={`${id}-k${i}-e${j}`}
585 label={name}
586 plain
587 dimColor
588 onPress={() => void act($, "open", `${r.dir}/${name}`, r.dir)}
589 />
590 ))}
591 </Box>
592 ))}
593 </Box>
594 )}
595 {r.notChecked.length > 0 && (
596 <Box flexDirection="column">
597 <Text dimColor>Not checked</Text>
598 {r.notChecked.map((n, i) => (
599 <Box key={`${id}-n${i}`} flexDirection="row" gap={1}>
600 <Text color="warning">!</Text>
601 <Text>{n}</Text>
602 </Box>
603 ))}
604 </Box>
605 )}
606 <Box flexDirection="row" gap={2}>
607 {/* A second Hide at the foot, so an open proof taller than the screen closes without scrolling back up. */}
608 <Button key={`${id}-hide`} label="▴ Close" onPress={hide} />
609 <Button
610 key={`${id}-folder`}
611 label="Reveal folder"
612 onPress={() => void act($, "reveal", `${r.dir}/proof.json`, r.dir)}
613 />
614 {r.url && <Link href={r.url} label="Open the PR" />}
615 </Box>
616 </Box>
617 );
618}
619
620/** The row as the engine draws it, with a proof button under it per record its refs find. */
621async function withProof(
622 $: EngineInterface,
623 e: RenderInput,
624 engineRow: () => Promise<RenderElement>,
625 refs: Ref[],
626 settings: { root: string; preview: string },
627 ownRefs: Ref[] = [],
628): Promise<RenderElement> {
629 if (refs.length === 0) return engineRow();
630 const instance = `${e.component}:${e.requestId}`;
631 // Before any wait, so the rows take their places in the order they draw.
632 placeOf(instance);
633 const root = await rootOf($, settings.root);
634 let displaced = false;
635 const records = await recordsIn($, root);
636 const here = await repoHere($);
637 const named = resolveRefs(refs, await ownRecords($, records, here, ownRefs), here);
638 // A record's proof is worth a button while its PR is open; a done PR's is history.
639 const live = [];
640 for (const r of named) {
641 if (!r.repo?.includes("/") || (await prState($, r.repo, r.pr, "open")) !== "done") live.push(r);
642 }
643 const newestOnly = (id: string) => {
644 const newest = newestMention(id, instance);
645 displaced ||= newest.displaced;
646 return newest.isNewest;
647 };
648 const found = live.filter((r) => newestOnly(r.dir)).slice(0, MAX_PER_REPLY);
649 // The rows that drew this record's button before draw again, without it.
650 if (displaced) $.ui.invalidate("ui.render");
651 if (found.length === 0) return engineRow();
652
653 const { Box, Text, Button } = $.ui.resolve(e);
654 const own = await engineRow();
655 const opened = await read($, open);
656 const mode = await modeFor($, settings.preview);
657
658 const blocks = [];
659 for (const r of found) {
660 const id = `${r.dir}@${e.requestId}`;
661 const isOpen = opened.includes(id);
662 const key = `pp-${r.repoDir}-${r.pr}`;
663 const toggle = () =>
664 void update($, open, (list) =>
665 list.includes(id) ? list.filter((x) => x !== id) : [...list, id].slice(-40),
666 );
667 blocks.push(
668 <Box key={key} flexDirection="column" paddingLeft={2}>
669 <Box flexDirection="row" gap={1}>
670 <Button key={`${key}-toggle`} label={isOpen ? "▾ Proof" : "▸ Proof"} onPress={toggle} />
671 {/* The row sits under the line that names the PR: its name only tells two buttons apart. */}
672 {found.length > 1 && <Text dimColor>{labelOf(r)}</Text>}
673 </Box>
674 {isOpen && (await details($, e, r, key, mode, toggle))}
675 </Box>,
676 );
677 }
678 return (
679 <Box flexDirection="column">
680 {own}
681 {blocks}
682 </Box>
683 );
684}
685
686export const register: Register = (on, options) => {
687 const rootSetting = String(options.root ?? "~/pr-proof");
688 const previewSetting = String(options.preview ?? "auto");
689 const settings = { root: rootSetting, preview: previewSetting };
690
691 // The records folder is a setting, so the agent writing records reads it
692 // from here rather than from the skill's default; the session's id marks
693 // the records it writes as its own.
694 on("prompt.compose", async ($, e, next) => {
695 const composed = await next(e);
696 const root = await rootOf($, rootSetting);
697 const id = await $.session.id().catch(() => undefined);
698 return {
699 sections: [
700 ...composed.sections,
701 {
702 id: "pr-proof:root",
703 text: `PR proof records (the pr-proof:record-proof skill) go in ${root}/<repo>/<pr>/: proof.json beside its screenshots and files${id ? `, its "session" set to "${id}"` : ""}.`,
704 scope: "session" as const,
705 },
706 ],
707 };
708 });
709
710 // A PR the person names is theirs to see, whichever session wrote its record.
711 // No origin is the person's own prompt too.
712 on("prompt.submit", async ($, e, next) => {
713 const from = (e.origin as PromptOrigin | undefined)?.kind;
714 if (from === undefined || PERSON.has(from)) {
715 const refs = findRefs(e.text);
716 if (refs.length > 0) await addSeen($, refs).catch(() => undefined);
717 }
718 return next(e);
719 }).catch(($, e, next) => next(e));
720
721 on("session.start", async ($, e, next) => {
722 await $.command.register({
723 name: "proof",
724 description: "List the PR proof records, or reveal one's folder (name#541)",
725 argumentHint: "[name#pr]",
726 });
727 return next(e);
728 });
729
730 // Matched to its own command: a hook with no matcher counts as answering every
731 // command, and its name shows on every command's output.
732 on("command.run", { command: "proof" }, async ($, e) => {
733 const root = await rootOf($, rootSetting);
734 const records = await recordsIn($, root, true);
735 const asked = e.args.trim();
736 if (asked) {
737 const hit = resolveRefs(findRefs(asked.includes("#") ? asked : `#${asked}`), records, await repoHere($))[0];
738 if (!hit) return { text: `No record for ${asked} under ${root}.` };
739 return { text: await act($, "reveal", `${hit.dir}/proof.json`, hit.dir) };
740 }
741 if (records.length === 0)
742 return { text: `No records under ${root}. Each one is <root>/<repo>/<pr>/proof.json.` };
743 const line = (r: ProofRecord) => `${labelOf(r)} ${countsOf(r)}${r.title ? ` ${r.title}` : ""}`;
744 const mine = await ownRecords($, records, await repoHere($));
745 const rest = records.filter((r) => !mine.includes(r));
746 if (mine.length === 0)
747 return { text: ["None from this session yet.", "", "Other sessions:", ...rest.map(line)].join("\n") };
748 return {
749 text: [
750 "This session:",
751 ...mine.map(line),
752 ...(rest.length ? ["", "Other sessions:", ...rest.map(line)] : []),
753 ].join("\n"),
754 };
755 });
756
757 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) =>
758 withProof($, e, () => next(e), findRefs(e.props.text), settings),
759 );
760
761 // Claude Code's own line for a `gh pr` call, "Created PR #549" or "Edited
762 // PR #549", is a ToolUse row, or a ToolGroup when it folded the call with
763 // others: the PR is in the URL the command printed. A create or an edit is
764 // this session's own work on the PR, after a resume too.
765 on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
766 const { tool, input, output } = e.props;
767 return withProof(
768 $,
769 e,
770 () => next(e),
771 findUrlRefs(ghPrText(tool, input, output)),
772 settings,
773 findUrlRefs(ghPrText(tool, input, output, GH_PR_CHANGE)),
774 );
775 });
776
777 on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
778 const printed = (verb?: RegExp) =>
779 findUrlRefs(e.props.calls.map((c) => ghPrText(c.tool, c.input, c.output, verb)).join("\n"));
780 return withProof($, e, () => next(e), printed(), settings, printed(GH_PR_CHANGE));
781 });
782
783 // What this session touched: a PR it opened or changed with `gh pr`, a record
784 // a tool wrote or named by its path. Replies draw these, and /proof lists
785 // them first.
786 on("tool.call", async ($, e, next) => {
787 const ran = await next(e);
788 try {
789 const root = await rootOf($, rootSetting);
790 const home = (await $.env.get("HOME")) ?? "";
791 // The fields that name what a call touched, not a Write's content, which
792 // may mention any record.
793 const args = e as unknown as Record<string, unknown>;
794 const input = ["command", "file_path", "path"]
795 .map((k) => args[k])
796 .filter((v): v is string => typeof v === "string")
797 .join("\n");
798 const output = ran.deny === undefined ? outputText(e.tool, ran.result) : "";
799 const paths = findRecordPaths(input, root, home);
800 // A record just written or moved into place is read on the next drawing,
801 // not up to INDEX_TTL_MS later.
802 if (paths.length > 0) index = undefined;
803 // `gh pr view` prints a PR's URL too: looking at a PR does not make it this session's.
804 const changed = ran.deny === undefined ? findUrlRefs(ghPrText(e.tool, e, ran.result, GH_PR_CHANGE)) : [];
805 const refs = [...paths, ...changed];
806 if (refs.length > 0) await addSeen($, refs);
807 // A merge just happened: every cached state may be stale, and the rows
808 // under a PR that is now merged draw again without their button.
809 if (/\bgh\s+pr\s+merge\b/.test(input) && ran.deny === undefined) {
810 prStates.clear();
811 $.ui.invalidate("ui.render");
812 }
813 // A PR opened with no record yet: say so to the model once, while the
814 // verification is fresh, rather than when someone asks for the proof.
815 const created = /\bgh\s+pr\s+create\b/.test(input) ? findUrlRefs(output) : [];
816 if (created.length > 0 && ran.deny === undefined && ran.isError !== true) {
817 const records = await recordsIn($, root, true);
818 const missing = created.filter((r) => resolveRefs([r], records, undefined).length === 0);
819 if (missing.length > 0) {
820 const names = missing.map((r) => `${r.name}#${r.pr}`).join(", ");
821 return {
822 ...ran,
823 context: [
824 ...(ran.context ?? []),
825 `pr-proof: no proof record for ${names} yet. Write it with the pr-proof:record-proof skill from the checks you ran, before reporting the PR ready.`,
826 ],
827 };
828 }
829 }
830 } catch {
831 // Bookkeeping only: never fail the call over it.
832 }
833 return ran;
834 }).catch(($, e, next) => next(e));
835};
836hooks/preview.ts 145 lines1import type { Preview } from "../types";
2
3/** ▀: the cell's foreground paints the top pixel, its background the bottom. */
4const UPPER_HALF = 0x2580;
5/** The terminal's own background, for the missing bottom pixel of an odd height. */
6const TERMINAL_DEFAULT = 0x01000000;
7
8export function fromBase64(text: string): Uint8Array {
9 const binary = atob(text);
10 const bytes = new Uint8Array(binary.length);
11 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
12 return bytes;
13}
14
15export function toBase64(bytes: Uint8Array): string {
16 let binary = "";
17 for (let i = 0; i < bytes.length; i += 0x8000) {
18 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
19 }
20 return btoa(binary);
21}
22
23const isSpace = (b: number | undefined) =>
24 b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d;
25
26/** Packs a picture into Raster cells, two pixel rows per cell row. */
27function toCells(
28 width: number,
29 height: number,
30 pixel: (x: number, y: number) => number,
31): Preview {
32 const rows = Math.ceil(height / 2);
33 const words = new Uint32Array(width * rows * 3);
34 for (let r = 0; r < rows; r++) {
35 for (let x = 0; x < width; x++) {
36 const w = (r * width + x) * 3;
37 words[w] = UPPER_HALF;
38 words[w + 1] = pixel(x, 2 * r);
39 words[w + 2] =
40 2 * r + 1 < height ? pixel(x, 2 * r + 1) : TERMINAL_DEFAULT;
41 }
42 }
43 return { columns: width, rows, cells: toBase64(new Uint8Array(words.buffer)) };
44}
45
46/** A binary PPM (P6, maxval 255): what ImageMagick and ffmpeg write. */
47export function ppmToPreview(ppm: Uint8Array): Preview | undefined {
48 if (ppm[0] !== 0x50 || ppm[1] !== 0x36) return undefined;
49
50 let i = 2;
51 const header: number[] = [];
52 while (header.length < 3) {
53 while (i < ppm.length && isSpace(ppm[i])) i++;
54 if (ppm[i] === 0x23) {
55 while (i < ppm.length && ppm[i] !== 0x0a) i++;
56 continue;
57 }
58 let value = 0;
59 let digits = 0;
60 for (
61 let b = ppm[i];
62 b !== undefined && b >= 0x30 && b <= 0x39;
63 b = ppm[++i]
64 ) {
65 value = value * 10 + (b - 0x30);
66 digits++;
67 }
68 if (digits === 0) return undefined;
69 header.push(value);
70 }
71 i++; // the one whitespace byte between maxval and the pixels
72
73 const [width = 0, height = 0, maxval = 0] = header;
74 if (maxval !== 255 || width < 1 || height < 1) return undefined;
75 if (ppm.length < i + width * height * 3) return undefined;
76
77 return toCells(width, height, (x, y) => {
78 const o = i + (y * width + x) * 3;
79 return ((ppm[o] ?? 0) << 16) | ((ppm[o + 1] ?? 0) << 8) | (ppm[o + 2] ?? 0);
80 });
81}
82
83/** One channel of a BI_BITFIELDS mask, read out and scaled to 0..255. */
84function channel(mask: number) {
85 if (mask === 0) return () => 0;
86 let shift = 0;
87 while (((mask >>> shift) & 1) === 0) shift++;
88 const max = mask >>> shift;
89 return (word: number) => Math.round((((word & mask) >>> shift) * 255) / max);
90}
91
92/**
93 * An uncompressed 24- or 32-bit BMP, bottom-up or top-down: what macOS's
94 * `sips` writes, so a Mac needs nothing installed for a preview.
95 */
96export function bmpToPreview(bmp: Uint8Array): Preview | undefined {
97 if (bmp[0] !== 0x42 || bmp[1] !== 0x4d || bmp.length < 54) return undefined;
98 const view = new DataView(bmp.buffer, bmp.byteOffset, bmp.byteLength);
99 const offset = view.getUint32(10, true);
100 const width = view.getInt32(18, true);
101 const signedHeight = view.getInt32(22, true);
102 const bits = view.getUint16(28, true);
103 const compression = view.getUint32(30, true);
104 const height = Math.abs(signedHeight);
105 if (width < 1 || height < 1 || (bits !== 24 && bits !== 32)) return undefined;
106
107 const stride = Math.ceil((width * bits) / 32) * 4;
108 if (bmp.length < offset + stride * height) return undefined;
109 const rowAt = (y: number) =>
110 offset + (signedHeight > 0 ? height - 1 - y : y) * stride;
111
112 if (bits === 24 || compression === 0) {
113 const size = bits / 8;
114 return toCells(width, height, (x, y) => {
115 const o = rowAt(y) + x * size;
116 return ((bmp[o + 2] ?? 0) << 16) | ((bmp[o + 1] ?? 0) << 8) | (bmp[o] ?? 0);
117 });
118 }
119 if (compression !== 3 && compression !== 6) return undefined;
120 const red = channel(view.getUint32(54, true));
121 const green = channel(view.getUint32(58, true));
122 const blue = channel(view.getUint32(62, true));
123 return toCells(width, height, (x, y) => {
124 const word = view.getUint32(rowAt(y) + x * 4, true);
125 return (red(word) << 16) | (green(word) << 8) | blue(word);
126 });
127}
128
129/** Whichever of the two the preview script printed. */
130export const decodePreview = (bytes: Uint8Array) =>
131 ppmToPreview(bytes) ?? bmpToPreview(bytes);
132
133const EXTENSIONS: Record<string, string> = {
134 "image/png": "png",
135 "image/jpeg": "jpg",
136 "image/gif": "gif",
137 "image/webp": "webp",
138};
139
140export const extensionFor = (mediaType: string) =>
141 EXTENSIONS[mediaType] ?? "png";
142
143export const isImagePath = (path: string) =>
144 /\.(png|jpe?g|gif|webp|bmp|tiff?|avif|heic)$/i.test(path);
145hooks/record.ts 251 lines1/** What one check came to. `skip` is a check that was planned and not run. */
2export type Result = "pass" | "fail" | "skip";
3
4/** One claim the agent checked, where, and the files that show it. */
5export type Check = {
6 claim: string;
7 where?: string;
8 result: Result;
9 /** File names inside the record's folder. */
10 evidence: string[];
11};
12
13/** One thing that changed on screen, as a before and an after picture. */
14export type Change = { title: string; before?: string; after?: string };
15
16/** A `proof.json`, read and checked, with where it was found. */
17export type ProofRecord = {
18 /** Absolute path of the folder holding the record and its pictures. */
19 dir: string;
20 /** The folder under the records root, usually the repo's name. */
21 repoDir: string;
22 /** `owner/name` when the record says so. */
23 repo?: string;
24 /** The id of the session that wrote it, when the record says so. */
25 session?: string;
26 pr: number;
27 title?: string;
28 url?: string;
29 /** Other names the PR is called by in chat, like `app` for `app#541`. */
30 aliases: string[];
31 checks: Check[];
32 changes: Change[];
33 notChecked: string[];
34};
35
36/** A PR named in a reply: `name#541`, `owner/name#541`, a PR URL or `#541`. */
37export type Ref = { owner?: string; name?: string; pr: number };
38
39const str = (v: unknown) =>
40 typeof v === "string" && v.trim() ? v.trim() : undefined;
41
42/**
43 * A file the record names, kept only when it stays inside the record's
44 * folder: no absolute path, no `..`, nothing but plain name characters.
45 */
46export function safeName(v: unknown): string | undefined {
47 const name = str(v);
48 if (!name || name.startsWith("/") || !/^[\w .\-/]+$/.test(name)) return;
49 if (name.split("/").some((part) => part === ".." || part === ""))
50 return undefined;
51 return name;
52}
53
54const RESULTS = new Set<Result>(["pass", "fail", "skip"]);
55
56/** Reads a record's text; anything that is not one answers undefined. */
57export function parseRecord(
58 text: string,
59 dir: string,
60 repoDir: string,
61 pr: number,
62): ProofRecord | undefined {
63 let raw: Record<string, unknown>;
64 try {
65 const parsed: unknown = JSON.parse(text);
66 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
67 return undefined;
68 raw = parsed as Record<string, unknown>;
69 } catch {
70 return undefined;
71 }
72 const list = (v: unknown) => (Array.isArray(v) ? v : []);
73 const url = str(raw.url);
74 return {
75 dir,
76 repoDir,
77 repo: str(raw.repo),
78 session: str(raw.session),
79 pr,
80 title: str(raw.title),
81 url: url && /^https?:\/\//.test(url) ? url : undefined,
82 aliases: list(raw.aliases).map(str).filter((a): a is string => !!a),
83 checks: list(raw.checks).flatMap((c) => {
84 const o = (c ?? {}) as Record<string, unknown>;
85 const claim = str(o.claim);
86 if (!claim) return [];
87 const result = RESULTS.has(o.result as Result)
88 ? (o.result as Result)
89 : "pass";
90 return [
91 {
92 claim,
93 where: str(o.where),
94 result,
95 evidence: list(o.evidence)
96 .map(safeName)
97 .filter((n): n is string => !!n),
98 },
99 ];
100 }),
101 changes: list(raw.changes).flatMap((c) => {
102 const o = (c ?? {}) as Record<string, unknown>;
103 const title = str(o.title);
104 const before = safeName(o.before);
105 const after = safeName(o.after);
106 return title && (before || after) ? [{ title, before, after }] : [];
107 }),
108 notChecked: list(raw.notChecked)
109 .map(str)
110 .filter((n): n is string => !!n),
111 };
112}
113
114const URL_REF = /https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/g;
115const NAMED_REF = /(?<![\w/#.-])(?:([\w.-]+)\/)?([A-Za-z][\w.-]*)#(\d+)\b/g;
116const BARE_REF = /(?<![\w/#.-])#(\d+)\b/g;
117
118/** Every PR a reply names, each once, in the order they first appear. */
119export function findRefs(text: string): Ref[] {
120 const found: { at: number; ref: Ref }[] = [];
121 const ref = (owner: string | undefined, name: string | undefined, pr: string | undefined): Ref => ({
122 ...(owner ? { owner } : {}),
123 ...(name ? { name } : {}),
124 pr: Number(pr),
125 });
126 for (const m of text.matchAll(URL_REF))
127 found.push({ at: m.index ?? 0, ref: ref(m[1], m[2], m[3]) });
128 for (const m of text.matchAll(NAMED_REF))
129 found.push({ at: m.index ?? 0, ref: ref(m[1], m[2], m[3]) });
130 for (const m of text.matchAll(BARE_REF))
131 found.push({ at: m.index ?? 0, ref: { pr: Number(m[1]) } });
132 const seen = new Set<string>();
133 return found
134 .sort((a, b) => a.at - b.at)
135 .map((f) => f.ref)
136 .filter((r) => {
137 const key = `${r.name ?? ""}#${r.pr}`;
138 if (seen.has(key)) return false;
139 seen.add(key);
140 return true;
141 });
142}
143
144/** The PRs a command's output links to (`gh pr create` prints its URL). */
145export function findUrlRefs(text: string): Ref[] {
146 return findRefs(
147 [...text.matchAll(URL_REF)].map((m) => m[0]).join(" "),
148 );
149}
150
151const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
152
153/**
154 * The records a tool call touched by path: `<root>/<repo>/<pr>`, written out
155 * or as `~/…` when the root sits under home.
156 */
157export function findRecordPaths(text: string, root: string, home: string): Ref[] {
158 const roots = [root];
159 if (home && root.startsWith(`${home}/`)) roots.push(`~${root.slice(home.length)}`);
160 const re = new RegExp(
161 `(?:${roots.map(escapeRe).join("|")})/([\\w][\\w.-]*)/(\\d+)(?![\\w])`,
162 "g",
163 );
164 return findRefs(
165 [...text.matchAll(re)].map((m) => `${m[1]}#${m[2]}`).join(" "),
166 );
167}
168
169/** The text a tool row can carry a PR URL in: Bash's stdout. */
170export function outputText(tool: string, output: unknown): string {
171 if (tool !== "Bash" || !output || typeof output !== "object") return "";
172 const stdout = (output as { stdout?: unknown }).stdout;
173 return typeof stdout === "string" ? stdout : "";
174}
175
176const GH_PR = /\bgh\s+pr\s+\w/;
177
178/** The `gh pr` calls that work on a PR rather than look at it. */
179export const GH_PR_CHANGE = /\bgh\s+pr\s+(?:create|edit|comment|ready)\b/;
180
181/**
182 * What a `gh pr` call printed (create, edit, comment, ready: each prints the
183 * PR's URL); "" for any other call, so a file's text or an API's JSON that
184 * happens to hold a PR link draws no button. `verb` narrows the calls.
185 */
186export function ghPrText(tool: string, input: unknown, output: unknown, verb = GH_PR): string {
187 const command = (input as { command?: unknown } | null)?.command;
188 if (typeof command !== "string" || !verb.test(command)) return "";
189 return outputText(tool, output);
190}
191
192/** `name#541`, the form the session's list of seen PRs keeps. */
193export const refKey = (r: Ref) => `${r.name ?? ""}#${r.pr}`;
194
195/** The key back to a ref. */
196export function parseRefKey(key: string): Ref | undefined {
197 const m = /^([\w.-]*)#(\d+)$/.exec(key);
198 return m ? { ...(m[1] ? { name: m[1] } : {}), pr: Number(m[2]) } : undefined;
199}
200
201const nameOf = (r: ProofRecord) => r.repo?.split("/").at(-1);
202
203/**
204 * The records a reply's refs point at, each once. A bare `#541` counts only
205 * when one record has that number, or one sits under the repo `here` names.
206 */
207export function resolveRefs(
208 refs: Ref[],
209 records: ProofRecord[],
210 here?: string,
211): ProofRecord[] {
212 const out: ProofRecord[] = [];
213 for (const ref of refs) {
214 const same = records.filter((r) => r.pr === ref.pr);
215 let hit: ProofRecord | undefined;
216 if (ref.name) {
217 const name = ref.name;
218 hit = same.find(
219 (r) =>
220 r.repoDir === name || nameOf(r) === name || r.aliases.includes(name),
221 );
222 } else {
223 hit =
224 same.length === 1
225 ? same[0]
226 : same.find((r) => r.repoDir === here || nameOf(r) === here);
227 }
228 if (hit && !out.includes(hit)) out.push(hit);
229 }
230 return out;
231}
232
233/** `web#541`-style label: the repo's name, else its folder. */
234export const labelOf = (r: ProofRecord) => `${nameOf(r) ?? r.repoDir}#${r.pr}`;
235
236/** "6 checks · 2 changes · 1 failed · 3 not checked", the parts that apply. */
237export function countsOf(r: ProofRecord): string {
238 const failed = r.checks.filter((c) => c.result === "fail").length;
239 const skipped =
240 r.checks.filter((c) => c.result === "skip").length + r.notChecked.length;
241 const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
242 return [
243 plural(r.checks.length, "check"),
244 r.changes.length ? plural(r.changes.length, "change") : "",
245 failed ? `${failed} failed` : "",
246 skipped ? `${skipped} not checked` : "",
247 ]
248 .filter(Boolean)
249 .join(" · ");
250}
251types/index.d.ts 14 lines1/** A half-block preview: Raster cells, two image pixels per cell (▀). */
2export type Preview = { columns: number; rows: number; cells: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'pr-proof': {
7 /** The reveals the person opened, `<record folder>@<message requestId>`. */
8 open: string[]
9 /** The PRs that are this session's, as `name#541`: one it opened or changed with `gh pr`, a record it wrote or named by path, or one the person named. */
10 seen: string[]
11 }
12 }
13}
14