Previews pasted images and images Claude sends, with Open and Reveal buttons (Explorer on WSL/Windows, Finder on macOS)

Claude Code mods (plugins of function hooks), one folder per mod. The repo is also a plugin marketplace: .claude-plugin/marketplace.json lists every mod.
| Mod | What it does |
|---|---|
| dev-up | Brings up a folder's dev stack from ~/.claude/dev-stacks/<name>.yml with /dev-up: docker compose, detached dev servers, one-off tasks and check scripts, one pass at a time, with each service as a colored dot under the prompt |
| image-peek | Previews pasted images and images Claude sends, with Open and Reveal (Explorer on WSL/Windows, Finder on macOS) buttons and /reveal-image, /open-image |
| pr-proof | Adds Reveal proof under a reply that names a PR: outlined before/after screenshots, the checks and what was not checked, from ~/pr-proof/<repo>/<pr>/proof.json; ships the record-proof skill that writes it |
/plugin install <mod> --marketplace chanlito/claude-mods
Run a mod from its folder: claude --plugin-dir ~/code/claude-mods/<mod>. Check it with claude plugin validate <mod> and claude plugin test <mod>.
A new mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and its hooks module, plus an entry in .claude-plugin/marketplace.json.
hooks/register.tsx 575 lines1import { atom, read, update } from "claude-code";
2import type { CommandRunInput, EngineInterface, Register, RenderInput } from "claude-code";
3
4import type { PeekImage, PeekPreview } from "../types";
5import { decodePreview, extensionFor, fromBase64, isImagePath } from "./preview";
6
7/** The thumbnail's box in pixels; a cell shows one pixel across, two down. */
8const PREVIEW_WIDTH = 56;
9const PREVIEW_HEIGHT = 36;
10/** The largest box a sharp image (kitty graphics) is drawn in, in cells. */
11const IMAGE_COLUMNS = 80;
12const IMAGE_ROWS = 24;
13/** How many images the session remembers (cards and commands). */
14const KEEP = 60;
15
16const images = atom({ plugin: "image-peek", key: "images" } as const, []);
17const seq = atom({ plugin: "image-peek", key: "seq" } as const, 0);
18
19/** Windows' own convert.exe (FAT to NTFS) is on a WSL PATH: not ImageMagick. */
20const HAS = `has() { p=$(command -v "$1" 2>/dev/null) && case "$p" in /mnt/*) false ;; esac; }`;
21
22/**
23 * Prints `<w> <h>` (or `<w>x<h>`) on one line, then the thumbnail as base64
24 * on the next: a PPM from ImageMagick or ffmpeg, a BMP from macOS's sips.
25 * Exits non-zero when none of them is installed; the card then has no preview.
26 *
27 * "$1" is always an absolute path and "$4" the format its extension names,
28 * forced on ImageMagick ("png:/path") so no prefix (\`|cmd\`, \`msl:\`,
29 * \`ephemeral:\`) or content-sniffed coder (SVG, MVG, MSL in a .png) runs, and
30 * ffmpeg reads only the local file.
31 */
32const PREVIEW_SCRIPT = `
33f="$1"; w="$2"; h="$3"; t="$4"
34case "$f" in /*) ;; *) exit 2 ;; esac
35${HAS}
36if has magick; then
37 magick identify -format "%w %h\\n" "$t:$f[0]" 2>/dev/null | head -n 1
38 magick "$t:$f[0]" -auto-orient -thumbnail "\${w}x\${h}" -background "#000000" -alpha remove -alpha off -depth 8 ppm:- | base64 | tr -d '\\n'
39elif has convert && has identify; then
40 identify -format "%w %h\\n" "$t:$f[0]" 2>/dev/null | head -n 1
41 convert "$t:$f[0]" -auto-orient -thumbnail "\${w}x\${h}" -background "#000000" -alpha remove -alpha off -depth 8 ppm:- | base64 | tr -d '\\n'
42elif has ffmpeg; then
43 ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -protocol_whitelist file "file:$f" 2>/dev/null | head -n 1
44 ffmpeg -v error -protocol_whitelist file -i "file:$f" -frames:v 1 -vf "scale=w=$w:h=$h:force_original_aspect_ratio=decrease" -pix_fmt rgb24 -f image2pipe -vcodec ppm - | base64 | tr -d '\\n'
45elif has sips; then
46 ow=$(sips -g pixelWidth "$f" 2>/dev/null | awk '/pixelWidth/ { print $2 }')
47 oh=$(sips -g pixelHeight "$f" 2>/dev/null | awk '/pixelHeight/ { print $2 }')
48 [ -n "$ow" ] && [ -n "$oh" ] || exit 3
49 echo "$ow $oh"
50 tw=$w; th=$((oh * w / ow))
51 if [ "$th" -gt "$h" ]; then th=$h; tw=$((ow * h / oh)); fi
52 [ "$tw" -ge 1 ] || tw=1; [ "$th" -ge 1 ] || th=1
53 tmp=$(mktemp "\${TMPDIR:-/tmp}/image-peek.XXXXXX") || exit 3
54 sips -s format bmp -z "$th" "$tw" "$f" --out "$tmp.bmp" >/dev/null 2>&1 && base64 < "$tmp.bmp" | tr -d '\\n'
55 ok=$?; rm -f "$tmp" "$tmp.bmp"; exit $ok
56else
57 exit 3
58fi
59`;
60
61/**
62 * Writes "$2", a PNG of "$1"'s first frame, for the terminal to draw: it reads
63 * the file itself, and only a PNG. The decoder is forced from "$3" as in
64 * PREVIEW_SCRIPT, and the PNG lands whole or not at all.
65 */
66const PNG_SCRIPT = `
67f="$1"; o="$2"; t="$3"; part="$2.part.png"
68case "$f" in /*) ;; *) exit 2 ;; esac
69mkdir -p "$(dirname "$o")" || exit 1
70${HAS}
71if has magick; then
72 magick "$t:$f[0]" -auto-orient "png:$part"
73elif has convert; then
74 convert "$t:$f[0]" -auto-orient "png:$part"
75elif has ffmpeg; then
76 ffmpeg -v error -protocol_whitelist file -i "file:$f" -frames:v 1 -f image2pipe -vcodec png - > "$part"
77elif has sips; then
78 sips -s format png "$f" --out "$part" >/dev/null 2>&1
79else
80 false
81fi && [ -s "$part" ] && mv -f "$part" "$o"
82ok=$?; rm -f "$part"; exit $ok
83`;
84
85/**
86 * Writes base64 from stdin to "$1" as bytes, making its folder. macOS before
87 * 13 spells the decode flag -D.
88 */
89const SAVE_SCRIPT = `
90mkdir -p "$(dirname "$1")" || exit 1
91cat > "$1.b64"
92base64 -d < "$1.b64" > "$1" 2>/dev/null || base64 -D < "$1.b64" > "$1"
93ok=$?; rm -f "$1.b64"; exit $ok
94`;
95
96type Os = "wsl" | "mac" | "linux" | "windows";
97type Thumbnail = { width?: number; height?: number; preview?: PeekPreview };
98type ImageBlock = {
99 type: "image";
100 source: { type: "base64"; media_type: string; data: string };
101};
102
103const isImageBlock = (block: unknown): block is ImageBlock => {
104 const b = block as Partial<ImageBlock> | null;
105 return (
106 b?.type === "image" &&
107 b.source?.type === "base64" &&
108 typeof b.source.data === "string"
109 );
110};
111
112const textOf = (content: readonly unknown[]) =>
113 content
114 .map((b) => {
115 const block = b as { type?: string; text?: string };
116 return block.type === "text" ? (block.text ?? "") : "";
117 })
118 .join("");
119
120const tilde = (path: string, home: string | undefined) =>
121 home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
122
123const dirname = (path: string) => path.replace(/\/[^/]*$/, "") || "/";
124
125/** Which desktop this session runs on; asked once per load. */
126let os: Promise<Os> | undefined;
127
128async function detectOs($: EngineInterface): Promise<Os> {
129 if (await $.env.get("WSL_DISTRO_NAME")) return "wsl";
130 if ((await $.env.get("OS")) === "Windows_NT") return "windows";
131 try {
132 const { stdout } = await $.process.run(["uname", "-s"]);
133 return stdout.includes("Darwin") ? "mac" : "linux";
134 } catch {
135 return "linux";
136 }
137}
138
139function where($: EngineInterface): Promise<Os> {
140 os ??= detectOs($);
141 return os;
142}
143
144/** Whether the terminal draws kitty graphics; asked once per load. */
145let graphics: Promise<boolean> | undefined;
146
147async function detectGraphics($: EngineInterface): Promise<boolean> {
148 // A multiplexer between Claude Code and the terminal drops the protocol.
149 const multiplexed =
150 (await $.env.get("TMUX")) ||
151 (await $.env.get("STY")) ||
152 (await $.env.get("ZELLIJ")) ||
153 (await $.env.get("HERDR_ENV"));
154 if (multiplexed) return false;
155 const program = ((await $.env.get("TERM_PROGRAM")) ?? "").toLowerCase();
156 const term = (await $.env.get("TERM")) ?? "";
157 return (
158 Boolean(await $.env.get("KITTY_WINDOW_ID")) ||
159 program === "ghostty" ||
160 /kitty|ghostty/.test(term)
161 );
162}
163
164/** `image` draws the picture itself, `blocks` the half-block preview. */
165async function previewMode(
166 $: EngineInterface,
167 setting: string,
168): Promise<"image" | "blocks"> {
169 if (setting === "image" || setting === "blocks") return setting;
170 graphics ??= detectGraphics($);
171 return (await graphics) ? "image" : "blocks";
172}
173
174/** The cell box a sharp image fits in, a cell being twice as tall as wide. */
175function imageBox(img: PeekImage) {
176 if (!img.width || !img.height) return undefined;
177 let columns = IMAGE_COLUMNS;
178 let rows = Math.round((columns * img.height) / img.width / 2);
179 if (rows > IMAGE_ROWS) {
180 rows = IMAGE_ROWS;
181 columns = Math.round((rows * 2 * img.width) / img.height);
182 }
183 return { columns: Math.max(1, columns), rows: Math.max(1, rows) };
184}
185
186async function windowsPath($: EngineInterface, path: string) {
187 return (await $.process.run(["wslpath", "-w", path])).stdout.trim();
188}
189
190/** Shows the file selected in Explorer (WSL, Windows) or Finder (macOS). */
191async function revealFile($: EngineInterface, path: string): Promise<string> {
192 switch (await where($)) {
193 case "wsl":
194 // explorer.exe exits 1 even when it worked.
195 await $.process.run([
196 "explorer.exe",
197 `/select,${await windowsPath($, path)}`,
198 ]);
199 return "Explorer";
200 case "windows":
201 await $.process.run(["explorer.exe", `/select,${path}`]);
202 return "Explorer";
203 case "mac":
204 await $.process.run(["open", "-R", path]);
205 return "Finder";
206 case "linux": {
207 const shown = await $.process
208 .run([
209 "dbus-send",
210 "--session",
211 "--dest=org.freedesktop.FileManager1",
212 "--type=method_call",
213 "/org/freedesktop/FileManager1",
214 "org.freedesktop.FileManager1.ShowItems",
215 `array:string:file://${encodeURI(path)}`,
216 "string:",
217 ])
218 .catch(() => ({ exitCode: 1 }));
219 if (shown.exitCode !== 0)
220 await $.process.run(["xdg-open", dirname(path)]);
221 return "the file manager";
222 }
223 }
224}
225
226/** Opens the file in the app the desktop associates with it. */
227async function openFile($: EngineInterface, path: string): Promise<void> {
228 switch (await where($)) {
229 case "wsl":
230 await $.process.run(["explorer.exe", await windowsPath($, path)]);
231 return;
232 case "windows":
233 await $.process.run(["cmd.exe", "/c", "start", "", path]);
234 return;
235 case "mac":
236 await $.process.run(["open", path]);
237 return;
238 case "linux":
239 await $.process.run(["xdg-open", path]);
240 return;
241 }
242}
243
244/** Opens or reveals one image, says so in a toast, and answers the same words. */
245async function act(
246 $: EngineInterface,
247 verb: "open" | "reveal",
248 img: PeekImage,
249): Promise<string> {
250 try {
251 if (verb === "open") {
252 await openFile($, img.path);
253 $.ui.toast(`Opened #${img.n} ${img.label}`);
254 return `Opened #${img.n} (${img.path}).`;
255 }
256 const app = await revealFile($, img.path);
257 $.ui.toast(`Revealed #${img.n} in ${app}`);
258 return `Revealed #${img.n} in ${app} (${img.path}).`;
259 } catch (error) {
260 const why = error instanceof Error ? error.message : String(error);
261 $.ui.toast(`Could not ${verb} #${img.n}: ${why}`);
262 return `Could not ${verb} #${img.n}: ${why}`;
263 }
264}
265
266/** The plain raster formats a preview is made from, by extension. */
267const PREVIEW_FORMATS: Record<string, string> = {
268 png: "png",
269 jpg: "jpeg",
270 jpeg: "jpeg",
271 gif: "gif",
272 webp: "webp",
273 bmp: "bmp",
274};
275
276const formatOf = (path: string): string | undefined =>
277 PREVIEW_FORMATS[path.split(".").at(-1)?.toLowerCase() ?? ""];
278
279async function thumbnail($: EngineInterface, path: string): Promise<Thumbnail> {
280 const format = formatOf(path);
281 if (!path.startsWith("/") || !format) return {};
282 try {
283 const { exitCode, stdout } = await $.process.run(
284 [
285 "sh",
286 "-c",
287 PREVIEW_SCRIPT,
288 "sh",
289 path,
290 String(PREVIEW_WIDTH),
291 String(PREVIEW_HEIGHT),
292 format,
293 ],
294 { timeoutMs: 15_000 },
295 );
296 if (exitCode !== 0) return {};
297 const [size = "", picture = ""] = stdout.split("\n");
298 const [width, height] = size.trim().split(/[ x]/).map(Number);
299 return {
300 width: width || undefined,
301 height: height || undefined,
302 preview: picture.trim()
303 ? decodePreview(fromBase64(picture.trim()))
304 : undefined,
305 };
306 } catch {
307 return {};
308 }
309}
310
311/**
312 * Writes a PNG copy of a non-PNG image, as `name` in today's folder, when
313 * images are drawn sharp, and answers its path; nothing when it is a PNG
314 * already or the copy failed, which leaves the half-block preview.
315 */
316async function pngCopy(
317 $: EngineInterface,
318 path: string,
319 name: string,
320 setting: string,
321): Promise<{ png?: string }> {
322 const format = formatOf(path);
323 if (!path.startsWith("/") || !format || format === "png") return {};
324 if ((await previewMode($, setting)) !== "image") return {};
325 const out = `${await todayDir($)}/${name}`;
326 const made = await $.process
327 .run(["sh", "-c", PNG_SCRIPT, "sh", path, out, format], {
328 timeoutMs: 30_000,
329 })
330 .catch(() => ({ exitCode: 1 }));
331 return made.exitCode === 0 ? { png: out } : {};
332}
333
334/** Where the mod keeps its files today: pasted images and PNG copies. */
335async function todayDir($: EngineInterface) {
336 const home = await $.env.get("HOME");
337 const day = new Date(await $.clock.now()).toISOString().slice(0, 10);
338 return `${home ?? "/tmp"}/.claude/image-peek/${day}`;
339}
340
341/** Numbers the images and adds them to the session's list. */
342async function remember($: EngineInterface, found: Omit<PeekImage, "n">[]) {
343 if (found.length === 0) return;
344 let first = 0;
345 await update($, seq, (last) => {
346 first = last + 1;
347 return last + found.length;
348 });
349 const numbered = found.map((img, i) => ({ ...img, n: first + i }));
350 await update($, images, (list) => [...list, ...numbered].slice(-KEEP));
351}
352
353/** One card per image: the half-block preview, its label, Open and Reveal. */
354async function cards(
355 $: EngineInterface,
356 e: RenderInput,
357 mine: PeekImage[],
358 setting: string,
359) {
360 const home = await $.env.get("HOME");
361 const { Box, Text, Button } = $.ui.resolve(e);
362 const terminal = e.surface === "terminal" ? $.ui.resolve(e) : undefined;
363 const Raster = terminal?.Raster;
364 const Image = terminal?.Image;
365 const sharp = terminal && (await previewMode($, setting)) === "image";
366
367 return mine.map((img) => {
368 // The terminal reads the file itself, and only a PNG.
369 const png = /\.png$/i.test(img.path) ? img.path : img.png;
370 const box = sharp && png ? imageBox(img) : undefined;
371 return (
372 <Box key={`card-${img.n}`} flexDirection="column" paddingLeft={2}>
373 {Image && box && png && (
374 <Image
375 key={`image-${img.n}`}
376 source={{ file: png, format: "png" }}
377 columns={box.columns}
378 rows={box.rows}
379 alt={`${img.label} (${img.width}×${img.height})`}
380 />
381 )}
382 {Raster && !box && img.preview && (
383 <Raster
384 key={`preview-${img.n}`}
385 columns={img.preview.columns}
386 rows={img.preview.rows}
387 cells={img.preview.cells}
388 />
389 )}
390 <Box flexDirection="row" gap={1}>
391 <Text dimColor>
392 #{img.n} {img.label}
393 {img.width && img.height ? ` · ${img.width}×${img.height}` : ""}
394 </Text>
395 <Button
396 key={`open-${img.n}`}
397 label="Open"
398 onPress={() => void act($, "open", img)}
399 />
400 <Button
401 key={`reveal-${img.n}`}
402 label="Reveal"
403 onPress={() => void act($, "reveal", img)}
404 />
405 </Box>
406 <Text dimColor wrap="truncate-start">
407 {tilde(img.path, home)}
408 </Text>
409 </Box>
410 );
411 });
412}
413
414/**
415 * /reveal-image and /open-image. Hooked once per command with a matcher: a
416 * hook with no matcher counts as answering every command, and its name shows
417 * on every command's output.
418 */
419async function imageCommand($: EngineInterface, e: CommandRunInput) {
420 const list = await read($, images);
421 const asked = e.args.trim().replace(/^#/, "");
422 const img = asked
423 ? list.find((one) => one.n === Number(asked))
424 : list.at(-1);
425 if (!img) {
426 return {
427 text:
428 list.length === 0
429 ? "No images yet: paste one, or ask Claude to send one."
430 : `No image #${asked}. Known: ${list.map((one) => `#${one.n}`).join(", ")}.`,
431 };
432 }
433 return {
434 text: await act($, e.command === "open-image" ? "open" : "reveal", img),
435 };
436}
437
438export const register: Register = (on, options) => {
439 const setting = String(options.preview ?? "auto");
440
441 on("session.start", async ($, e, next) => {
442 await $.command.register({
443 name: "reveal-image",
444 description:
445 "Reveal an image in Explorer/Finder (latest, or #n from its card)",
446 argumentHint: "[n]",
447 });
448 await $.command.register({
449 name: "open-image",
450 description:
451 "Open an image in its default app (latest, or #n from its card)",
452 argumentHint: "[n]",
453 });
454 return next(e);
455 });
456
457 on("command.run", { command: "reveal-image" }, imageCommand);
458 on("command.run", { command: "open-image" }, imageCommand);
459
460 // A pasted image lives only inside the prompt row, as base64: keep a copy
461 // on disk so there is a file to open and reveal.
462 on("session.append", { door: "prompt" }, async ($, e, next) => {
463 const stored = await next(e);
464 const content = e.message.content as readonly unknown[];
465 const blocks = content.filter(isImageBlock);
466 if (blocks.length === 0 || e.agentId) return stored;
467
468 const dir = await todayDir($);
469 const prompt = textOf(content);
470 const numbers = [...prompt.matchAll(/\[Image #(\d+)\]/g)].map((m) => m[1]);
471
472 const found: Omit<PeekImage, "n">[] = [];
473 for (const [i, block] of blocks.entries()) {
474 const name = `${e.uuid.slice(0, 8)}-${i + 1}.${extensionFor(block.source.media_type)}`;
475 const path = `${dir}/${name}`;
476 const saved = await $.process
477 .run(["sh", "-c", SAVE_SCRIPT, "sh", path], {
478 stdin: block.source.data,
479 timeoutMs: 30_000,
480 })
481 .catch(() => ({ exitCode: 1 }));
482 if (saved.exitCode !== 0) continue;
483 found.push({
484 row: e.uuid,
485 prompt,
486 path,
487 label: `Image #${numbers[i] ?? i + 1}`,
488 from: "pasted",
489 ...(await thumbnail($, path)),
490 ...(await pngCopy($, path, name.replace(/\.[^.]*$/, ".png"), setting)),
491 });
492 }
493 await remember($, found);
494 return stored;
495 }).catch(($, e, next) => next(e));
496
497 on("tool.call", { tool: "SendUserFile" }, async ($, e, next) => {
498 const ran = await next(e);
499 if (ran.deny !== undefined || ran.isError) return ran;
500
501 const result = ran.result as
502 { attachments?: { path: string; isImage?: boolean }[] } | undefined;
503 const paths = result?.attachments
504 ? result.attachments
505 .filter((a) => a.isImage || isImagePath(a.path))
506 .map((a) => a.path)
507 : e.files.filter(isImagePath);
508
509 const found: Omit<PeekImage, "n">[] = [];
510 for (const [i, given] of paths.entries()) {
511 // Open, Reveal and the preview only ever get an absolute path.
512 const path = given.startsWith("/")
513 ? given
514 : (
515 await $.process
516 .run(["realpath", "--", given])
517 .catch(() => ({ exitCode: 1, stdout: "" }))
518 ).stdout.trim();
519 if (!path.startsWith("/")) continue;
520 found.push({
521 row: e.tool_use_id,
522 path,
523 label: path.split("/").at(-1) ?? path,
524 from: "claude",
525 ...(await thumbnail($, path)),
526 ...(await pngCopy($, path, `${e.tool_use_id}-${i + 1}.png`, setting)),
527 });
528 }
529 await remember($, found);
530 return ran;
531 }).catch(($, e, next) => next(e));
532
533 on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
534 const list = await read($, images);
535 let mine = list.filter((img) => img.row === e.requestId);
536 // The row's id should be the prompt's uuid; match on its text if not.
537 if (mine.length === 0 && e.props.text.includes("[Image #")) {
538 const last = [...list]
539 .reverse()
540 .find((img) => img.from === "pasted" && img.prompt === e.props.text);
541 mine = last ? list.filter((img) => img.row === last.row) : [];
542 }
543 if (mine.length === 0) return next(e);
544
545 const { Box } = $.ui.resolve(e);
546 const own = await next(e);
547 return (
548 <Box flexDirection="column">
549 {own}
550 {await cards($, e, mine, setting)}
551 </Box>
552 );
553 });
554
555 on(
556 "ui.render",
557 { component: "ToolUse", props: { tool: "SendUserFile" } },
558 async ($, e, next) => {
559 const mine = (await read($, images)).filter(
560 (img) => img.row === e.requestId,
561 );
562 if (mine.length === 0) return next(e);
563
564 const { Box } = $.ui.resolve(e);
565 const own = await next(e);
566 return (
567 <Box flexDirection="column">
568 {own}
569 {await cards($, e, mine, setting)}
570 </Box>
571 );
572 },
573 );
574};
575hooks/preview.ts 145 lines1import type { PeekPreview } 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): PeekPreview {
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): PeekPreview | 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): PeekPreview | 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);
145types/index.d.ts 30 lines1/** A half-block preview: Raster cells, two image pixels per cell (▀). */
2export type PeekPreview = { columns: number; rows: number; cells: string }
3
4/** One image the mod has seen: pasted by the person or sent by Claude. */
5export type PeekImage = {
6 /** Session-wide number shown on the card, `#n`; what the commands take. */
7 n: number
8 /** The transcript row the card sits under: a prompt's uuid or a tool_use_id. */
9 row: string
10 /** Absolute path on this machine. */
11 path: string
12 /** `Image #2` for a paste, the file name for one Claude sent. */
13 label: string
14 /** A paste's prompt text, to find its row if the row id differs. */
15 prompt?: string
16 from: 'pasted' | 'claude'
17 /** Original size in pixels, when ImageMagick could read it. */
18 width?: number
19 height?: number
20 preview?: PeekPreview
21 /** A PNG copy of a non-PNG image, the file a sharp image is drawn from. */
22 png?: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'image-peek': { images: PeekImage[]; seq: number }
28 }
29}
30