Shows the images you paste and the ones Claude reads, receives or creates in a small strip above the prompt: PNG, JPEG, GIF, WebP, HEIC, AVIF, TIFF, BMP and…

A Claude Code mod that shows images in your terminal, in a small strip right above the prompt. Claude Code shows an image you paste as [Image #1], and an image Claude reads or makes as nothing at all; the Lightbox draws them:
Image #1, Image #2 as the transcript names themscreencapture, xcrun simctl io booted screenshot, an image generator's output (but not Claude's own scratch files)show tool/lightbox path/to/imageIt reads PNG, JPEG, GIF, WebP, HEIC/HEIF, AVIF, TIFF, BMP and SVG. Each image is decoded once into a PNG of at most 480 pixels for the strip: with sips first for HEIC on macOS, which is several times faster there, and ImageMagick first for everything else. The larger view gets a 1280-pixel picture only when you open it.
Requires Claude Code 2.1.288 or later.
/plugin marketplace add nuko-nova-dynamics/marketplace
/plugin install lightbox@nuko-nova-tools
‹ prev and next › to step through the images, larger for the larger view, open to open the image in Preview, fold to fold the strip and hide to hide it. From the keyboard, ctrl+x then Tab moves to the controls and Enter presses one./lightbox opens a folded or hidden strip and hides an open one. /lightbox <path> shows a file, /lightbox view opens the larger view, and /lightbox clear empties the reel.The larger view is a pane with the current image as large as the pane allows and thumbnails of the images around it; the strip steps aside while it shows. In it, h and l step, o opens in Preview, r reveals in Finder, c copies the path and x removes the image.
Open and Reveal appear only on a Mac you are sitting at, not over SSH. The reel keeps the last 24 images per session, with pictures for the 12 most recent held in memory.
Where Claude Code can draw real pixels (Ghostty or kitty with nothing in between), the Lightbox uses them. Inside a multiplexer such as herdr, tmux or zellij, and over SSH, it draws the picture with Unicode quadrant blocks instead: each terminal cell shows two colors over a 2×2 grid, fitted to the pixels under it. That works in any terminal with true color. The renderer option forces either one: pixels, cells, or auto (the default). pixels only chooses which the Lightbox draws: where Claude Code itself does not draw pictures, an image then shows as its name.

herdr renders kitty graphics, but Claude Code decides whether to draw pixels by asking the terminal its name and accepting only kitty or ghostty. herdr answers libghostty, so Claude Code shows images as text there. Set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 in herdr panes to turn pixels on; the Lightbox follows the same variable. In ~/.zshrc:
if [[ -n $HERDR_ENV && -n $GHOSTTY_RESOURCES_DIR ]]; then
export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
fi
Check for Ghostty with GHOSTTY_RESOURCES_DIR, not TERM_PROGRAM: herdr 0.9.3 and later set TERM_PROGRAM=herdr in every pane, so a $TERM_PROGRAM == ghostty test no longer matches. Claude Code reads the variable when it starts, so restart it from a new pane after changing this. A pane whose shell started before the change keeps running without it, and every Claude Code started there draws blocks; the Lightbox says so once, at the first image.
A terminal stretches a picture to fill the cells it is given, so the Lightbox needs to know how tall a cell is against its width. The cellAspect option says so: about 2.1 for most fonts, more with taller lines (Ghostty's adjust-cell-height). JetBrains Mono at 14 points with adjust-cell-height = 10% is 2.5. Set it in ~/.claude/settings.json:
"pluginConfigs": { "lightbox@nuko-nova-tools": { "options": { "cellAspect": 2.5 } } }
autoOpen (on by default): a new image shows the strip again after you hid it. Off, a toast says one arrived instead.renderer: auto, cells or pixels, as above.cellAspect: a cell's height against its width, as above.claude plugin validate .
claude plugin test .
claude --plugin-dir . # a session that reloads the mod on save
hooks/register.tsx is the mod. hooks/images.ts and hooks/cells.ts touch nothing outside it: formats, PNG headers, paths and layout, and the quadrant-cell renderer (a BMP reader and the fitting of two colors to each 2×2 block).
MIT
hooks/register.tsx 1013 lines1// Lightbox: every image the session reads, receives or creates, in a small strip above the prompt, with a pane
2// for a larger look on request. Images come from Read results, image blocks in tool results (screenshots), image
3// files a tool call just wrote, images pasted into a prompt, the `show` tool Claude calls to send one, and
4// `/lightbox <path>`.
5//
6// Pictures draw as real pixels (the Image element, kitty graphics) where Claude Code can, and otherwise as
7// Unicode quadrant blocks in colored cells (the Raster element), which work in any terminal and over SSH.
8
9import { atom, read, update } from "claude-code";
10import type { ElementConstructor, EngineInterface, ImageProps, RasterProps, Register, RenderChildren } from "claude-code";
11import type { LightboxShot } from "../types";
12import { decodeBase64, quadrantCells, readBmp } from "./cells.ts";
13import { absolutePath, ago, basename, CELL_ASPECT, decodedSize, fit, formatLabel, imageBlocks, imagePathsIn, imagesAfterText, isPng, quotedImagePaths, mimeForPath, pastedImagePaths, pngSize, toolLabel } from "./images.ts";
14
15type Api = EngineInterface;
16type Call = { readonly tool: string; readonly [field: string]: unknown };
17type Bytes = { base64: string; mime: string };
18type NewShot = { title: string; origin: string; mime: string; path?: string; caption?: string; originalWidth?: number; originalHeight?: number };
19type Size = "strip" | "full";
20type Row = { readonly message: { readonly type: string; readonly name?: string; readonly content: readonly unknown[] }; readonly origin: { readonly kind: string }; readonly agentId?: string };
21
22const PANE = "lightbox";
23const SHOW_TOOL = "mcp__lightbox__show";
24const shots = atom({ plugin: "lightbox", key: "shots" } as const, [] as LightboxShot[]);
25const current = atom({ plugin: "lightbox", key: "current" } as const, 0);
26const hidden = atom({ plugin: "lightbox", key: "hidden" } as const, false);
27const folded = atom({ plugin: "lightbox", key: "folded" } as const, false);
28
29const REEL_SIZE = 24;
30const PIXELS_KEPT = 12;
31const CELLS_KEPT = 64;
32// The Image element takes at most 2 MiB of PNG.
33const PNG_LIMIT = 2 * 1024 * 1024 - 4096;
34// And at most this many pixels on a side.
35const PNG_SIDE_LIMIT = 4096;
36// The strip's picture: a few rows tall, so a small PNG. Every redraw carries it, so it must stay small.
37const STRIP_SIDE = 480;
38// Rows of picture inside the strip's frame; the frame adds one above and one below.
39const STRIP_ROWS = 4;
40const STRIP_MAX_COLUMNS = 40;
41// The pane's picture, prepared only once the pane asks for it.
42const FULL_SIDE = 1280;
43const SMALLER_SIDE = 800;
44const THUMB_COLUMNS = 12;
45const THUMB_ROWS = 4;
46const FORMATS = "PNG, JPEG, GIF, WebP, HEIC/HEIF, AVIF, TIFF, BMP and SVG";
47// Who an image came from, at a glance: the person's in the accent color, Claude's in Claude's own orange.
48const YOURS = "cyan";
49const CLAUDES = "#D97757";
50// Up to this many images the strip shows one dot each; past it, a count.
51const DOTS = 12;
52// Images from one sender this close together arrived together: several pasted at once, or sent at once.
53const BATCH_MS = 3000;
54// The most images one paste or one tool result puts on the reel.
55const CAPTURE_MAX = 12;
56// The most pictures of one batch the strip shows side by side.
57const BATCH_SHOWN = 4;
58
59// Prints, as base64, a PNG of the first frame of an image, no longer than $2 pixels on its longest side, and
60// writes the original size to stderr as "dims W H". $1 is a file path, or "-" to read base64 from stdin; $3 is
61// its MIME type. The image is decoded once: the size comes from the header. sips (macOS ImageIO) goes first for
62// HEIC, which it decodes several times faster than ImageMagick; ImageMagick goes first for the rest (SVG among
63// them), and JPEGs decode at reduced scale.
64const CONVERT = [
65 'umask 077; in="$1"; side="$2"; mime="$3"; tmp="$(mktemp "${TMPDIR:-/tmp}/lightbox.XXXXXX")" || exit 1',
66 'trap \'rm -f "$tmp" "$tmp.in" "$tmp.png"\' EXIT',
67 'if [ "$in" = "-" ]; then base64 -d > "$tmp.in" || exit 1; in="$tmp.in"; fi',
68 'with_sips() {',
69 ' command -v sips >/dev/null 2>&1 || return 1',
70 ' sips -s format png -Z "$side" "$in" --out "$tmp.png" >/dev/null 2>&1; [ -s "$tmp.png" ] || return 1',
71 ' dims="$(sips -g pixelWidth -g pixelHeight "$in" 2>/dev/null | awk \'/pixelWidth/{w=$2}/pixelHeight/{h=$2}END{if(w)print w, h}\')"',
72 // sips keeps an EXIF orientation as a tag rather than turning the pixels, which the terminal draws as they lie.
73 ' if command -v magick >/dev/null 2>&1; then',
74 ' case "$(magick identify -format "%[orientation]" "$tmp.png" 2>/dev/null)" in LeftTop|RightTop|RightBottom|LeftBottom) dims="$(echo "$dims" | awk \'{print $2, $1}\')" ;; esac',
75 ' magick "$tmp.png" -auto-orient "$tmp.png" 2>/dev/null',
76 ' fi',
77 ' if [ -n "$dims" ]; then echo "dims $dims" >&2; fi',
78 '}',
79 'with_magick() {',
80 ' if command -v magick >/dev/null 2>&1; then id="magick identify"; cv=magick; elif command -v convert >/dev/null 2>&1; then id=identify; cv=convert; else return 1; fi',
81 ' $id -ping -format "dims %w %h\\n" "$in[0]" >&2 2>/dev/null',
82 ' $cv -define jpeg:size=$((side * 2))x$((side * 2)) "$in[0]" -auto-orient -resize "${side}x${side}>" -depth 8 "png:$tmp.png" 2>/dev/null; [ -s "$tmp.png" ]',
83 '}',
84 'case "$mime" in image/heic|image/heif) with_sips || with_magick ;; *) with_magick || with_sips ;; esac',
85 'if [ ! -s "$tmp.png" ]; then echo "no converter here could read this image (ImageMagick, or sips on macOS)" >&2; exit 1; fi',
86 'base64 < "$tmp.png" | tr -d "\\n"'
87].join("\n");
88
89// Prints, as base64, an uncompressed BMP of a PNG (read from stdin as base64) resized to exactly $1 by $2 pixels:
90// the pixels a Raster's quadrant cells are fitted to.
91const TO_BMP = [
92 'umask 077; w="$1"; h="$2"; tmp="$(mktemp "${TMPDIR:-/tmp}/lightbox.XXXXXX")" || exit 1',
93 'trap \'rm -f "$tmp" "$tmp.png" "$tmp.bmp"\' EXIT',
94 'base64 -d > "$tmp.png" || exit 1',
95 'if command -v magick >/dev/null 2>&1; then magick "$tmp.png[0]" -background black -alpha remove -alpha off -resize "${w}x${h}!" bmp3:-',
96 'elif command -v convert >/dev/null 2>&1; then convert "$tmp.png[0]" -background black -alpha remove -alpha off -resize "${w}x${h}!" bmp3:-',
97 'elif command -v sips >/dev/null 2>&1; then sips -s format bmp -z "$h" "$w" "$tmp.png" --out "$tmp.bmp" >/dev/null && cat "$tmp.bmp"',
98 'else echo "no image converter found: install ImageMagick" >&2; exit 127',
99 'fi | base64 | tr -d "\\n"'
100].join("\n");
101
102// Pictures ready to draw, by shot id: the module's own memory, so a reload prepares them again.
103const pixels = new Map<string, { strip?: string; full?: string }>();
104// Image bytes that arrived with no file behind them, kept while the shot is among the recent few.
105const inline = new Map<string, Bytes>();
106const preparing = new Set<string>();
107// Quadrant cells by shot and size; and the jobs computing them.
108const cellCache = new Map<string, string>();
109const cellJobs = new Set<string>();
110let autoOpen = true;
111let canOpen = false;
112let rendererOption = "auto";
113let drawsPixels = false;
114let cellAspect = CELL_ASPECT;
115// Drawing cells only because this shell lacks CLAUDE_CODE_FORCE_TERMINAL_IMAGES, inside a multiplexer within a
116// terminal that draws pixels: said once, at the first image, since a fresh shell fixes it.
117let pixelsHint = "";
118// Where images that came with no file behind them get a private copy, so they can be drawn again after the mod
119// reloads (an option changed) and opened in another app; set at session start.
120let cacheDir = "";
121const EXTENSION_BY_MIME: Record<string, string> = { "image/jpeg": "jpg", "image/svg+xml": "svg" };
122
123/** Writes image bytes to a file only this user can read, and returns its path, or undefined when it cannot. */
124async function keepCopy($: Api, id: string, bytes: Bytes): Promise<string | undefined> {
125 if (!cacheDir) return undefined;
126 const name = `${id}.${EXTENSION_BY_MIME[bytes.mime] ?? bytes.mime.replace(/^image\//, "").replace(/[^a-z0-9]/gi, "")}`;
127 const run = await $.process.run(["sh", "-c", 'umask 077 && mkdir -p "$1" && base64 -d > "$1/$2"', "lightbox", cacheDir, name], { stdin: bytes.base64 }).catch(() => null);
128 return run?.exitCode === 0 ? `${cacheDir}/${name}` : undefined;
129}
130
131// True until the first session start after this module loaded: its memory of pictures and bytes starts empty.
132let freshModule = true;
133
134const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).split("\n")[0]?.slice(0, 160) ?? "";
135const newId = () => (globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`);
136
137async function convert($: Api, input: string, stdin: string, side: number, mime: string): Promise<{ png: string; dims: { width: number; height: number } | null }> {
138 const run = await $.process.run(["sh", "-c", CONVERT, "lightbox", input, String(side), mime], { stdin, timeoutMs: 30_000 });
139 // Too large to come back whole: the caller tries a smaller size.
140 if (run.isStdoutTruncated) return { png: "", dims: null };
141 const png = run.stdout.replace(/\s+/g, "");
142 if (!isPng(png)) throw new Error(run.stderr.trim().split("\n").filter((l) => !l.startsWith("dims ")).pop() || `the converter exited with ${run.exitCode}`);
143 const m = run.stderr.match(/dims (\d+) (\d+)/);
144 return { png, dims: m ? { width: Number(m[1]), height: Number(m[2]) } : null };
145}
146
147/**
148 * Turns a shot's bytes or file into a PNG it can draw at one size, notes its original size, then marks it ready.
149 * The strip's picture comes first; the pane's larger one only when the pane is opened.
150 */
151async function prepare($: Api, id: string, which: Size): Promise<void> {
152 const job = `${id}:${which}`;
153 if (pixels.get(id)?.[which] || preparing.has(job)) return;
154 const shot = (await read($, shots)).find((s) => s.id === id);
155 if (!shot) return;
156 preparing.add(job);
157 try {
158 const bytes = inline.get(id);
159 const input = bytes ? "-" : (shot.path ?? shot.copy);
160 if (!input) throw new Error("its bytes are no longer in memory");
161 const stdin = bytes?.base64 ?? "";
162 const side = which === "strip" ? STRIP_SIDE : FULL_SIDE;
163 let picture = "";
164 let original = shot.originalWidth && shot.originalHeight ? { width: shot.originalWidth, height: shot.originalHeight } : null;
165 if (bytes && isPng(bytes.base64)) {
166 const size = pngSize(bytes.base64);
167 original = original ?? size;
168 // A PNG small enough for this size is drawn as it is.
169 if (size && decodedSize(bytes.base64) <= PNG_LIMIT && Math.max(size.width, size.height) <= (which === "full" ? PNG_SIDE_LIMIT : side)) picture = bytes.base64;
170 }
171 if (!picture && !bytes && which === "full" && shot.mime === "image/png") {
172 const file = await $.fs.read(input, { as: "bytes" }).catch(() => null);
173 const size = file ? pngSize(file.base64) : null;
174 if (file && size && decodedSize(file.base64) <= PNG_LIMIT && Math.max(size.width, size.height) <= PNG_SIDE_LIMIT) picture = file.base64;
175 }
176 if (!picture) {
177 const converted = await convert($, input, stdin, side, shot.mime);
178 picture = converted.png;
179 original = original ?? converted.dims;
180 }
181 if (!picture || decodedSize(picture) > PNG_LIMIT) picture = (await convert($, input, stdin, SMALLER_SIDE, shot.mime)).png;
182 if (!picture || decodedSize(picture) > PNG_LIMIT) throw new Error("the picture is too large to draw, even made smaller");
183 const drawn = pngSize(picture);
184 pixels.set(id, { ...pixels.get(id), [which]: picture });
185 await update($, shots, (list) =>
186 list.map((s) =>
187 s.id === id
188 ? { ...s, status: "ready" as const, ...(drawn ?? {}), ...(original ? { originalWidth: original.width, originalHeight: original.height } : {}) }
189 : s
190 )
191 );
192 } catch (err) {
193 // Without the strip's picture there is nothing to show; without the pane's, the strip's stands in.
194 if (which === "strip") await update($, shots, (list) => list.map((s) => (s.id === id ? { ...s, status: "failed" as const, note: message(err) } : s)));
195 else $.ui.log(`lightbox: larger picture for ${shot.title}: ${message(err)}`, { to: "debug" });
196 } finally {
197 preparing.delete(job);
198 }
199}
200
201/** Fits quadrant cells to a shot's strip picture at one size, then asks for a redraw. */
202async function renderCells($: Api, id: string, columns: number, rows: number): Promise<void> {
203 const key = `${id}:${columns}x${rows}`;
204 if (cellCache.has(key) || cellJobs.has(key)) return;
205 const picture = pixels.get(id)?.strip;
206 if (!picture) return;
207 cellJobs.add(key);
208 try {
209 const run = await $.process.run(["sh", "-c", TO_BMP, "lightbox", String(columns * 2), String(rows * 2)], { stdin: picture, timeoutMs: 15_000 });
210 const bmp = readBmp(decodeBase64(run.stdout));
211 if (!bmp) throw new Error(run.stderr.trim().split("\n").pop() || "could not read the picture's pixels");
212 cellCache.set(key, quadrantCells(bmp, columns, rows));
213 while (cellCache.size > CELLS_KEPT) cellCache.delete(cellCache.keys().next().value as string);
214 $.ui.invalidate("ui.render");
215 } catch (err) {
216 $.ui.log(`lightbox: cells for ${id}: ${message(err)}`, { to: "debug" });
217 } finally {
218 cellJobs.delete(key);
219 }
220}
221
222// Shots being put on the reel: their bytes are held before the reel lists them, so eviction leaves them be.
223const adding = new Set<string>();
224// The shot whose capture wrote the reel last, and its place there: the one the selection follows.
225let newest = "";
226let newestIndex = 0;
227
228/**
229 * About how many rows a tree takes: a Text or a string is one, a column adds its children, a row takes its tallest,
230 * borders and vertical padding add theirs. Wrapped text is not counted, so it can come out short.
231 */
232function rowsOf(node: unknown): number {
233 if (node === null || node === undefined || typeof node === "boolean") return 0;
234 if (typeof node === "string" || typeof node === "number") return 1;
235 if (Array.isArray(node)) return node.reduce((sum: number, child) => sum + rowsOf(child), 0);
236 const el = node as { type?: string; props?: Record<string, unknown>; children?: unknown[] };
237 if (el.type !== "Box") return 1;
238 const props = el.props ?? {};
239 if (props.display === "none") return 0;
240 if (typeof props.height === "number") return props.height;
241 const children = (el.children ?? []).map(rowsOf);
242 const inner = props.flexDirection === "column" ? children.reduce((a, b) => a + b, 0) : Math.max(0, ...children);
243 const padding = (n: unknown) => (typeof n === "number" ? n : 0);
244 const vertical = padding(props.paddingTop ?? props.paddingY ?? props.padding) + padding(props.paddingBottom ?? props.paddingY ?? props.padding);
245 return inner + vertical + (props.borderStyle ? 2 : 0);
246}
247
248type Tone = "primary" | "neutral" | "danger";
249const TONE_COLOR: Record<Tone, string> = { primary: "suggestion", neutral: "inactive", danger: "error" };
250
251/**
252 * A button that looks like one: the label with a space either side, inside a rounded outline in its tone that turns
253 * to the text color under the pointer. The whole row inside the outline is the label, so it is easy to hit.
254 */
255function outlined(
256 Box: ElementConstructor<any>,
257 Button: ElementConstructor<any>,
258 id: string,
259 label: string,
260 onPress: () => void,
261 tone: Tone = "neutral"
262) {
263 return (
264 <Box key={`b-${id}`} borderStyle="round" borderColor={TONE_COLOR[tone]} hover={{ borderColor: "text" }} flexShrink={0}>
265 <Button key={id} plain label={` ${label} `} onPress={onPress} />
266 </Box>
267 );
268}
269
270/**
271 * Drops what shots that left the reel held. Every shot on it keeps its small strip picture, so it can still be
272 * shown; past the recent few it lets go of its larger picture, and of its original bytes once the strip's is made.
273 */
274function evict(list: readonly LightboxShot[]): void {
275 const onReel = new Set(list.map((s) => s.id));
276 const recent = new Set(list.slice(-PIXELS_KEPT).map((s) => s.id));
277 for (const [id, picture] of pixels) {
278 if (adding.has(id)) continue;
279 if (!onReel.has(id)) pixels.delete(id);
280 else if (!recent.has(id) && picture.full) pixels.set(id, picture.strip ? { strip: picture.strip } : {});
281 }
282 for (const id of inline.keys()) {
283 if (adding.has(id) || recent.has(id) || (onReel.has(id) && !pixels.get(id)?.strip)) continue;
284 inline.delete(id);
285 }
286 for (const key of cellCache.keys()) if (!onReel.has(key.split(":")[0] ?? "")) cellCache.delete(key);
287 for (const [id, path] of copies) {
288 if (adding.has(id) || onReel.has(id)) continue;
289 copies.delete(id);
290 gone.push(path);
291 }
292}
293
294// The private copies of shots on the reel, by id; and copies whose shots left it, to delete.
295const copies = new Map<string, string>();
296let gone: string[] = [];
297
298/** Deletes the private copies whose shots left the reel: only files in this session's own copy folder. */
299async function sweep($: Api): Promise<void> {
300 const paths = gone.filter((path) => cacheDir && path.startsWith(`${cacheDir}/`));
301 gone = [];
302 if (paths.length) await $.process.run(["rm", "-f", "--", ...paths]).catch(() => null);
303}
304
305/** A new image shows the strip again, unfolded; with autoOpen off, a hidden strip stays hidden and a toast says so. */
306async function announce($: Api, title: string): Promise<void> {
307 await update($, folded, () => false);
308 if (autoOpen) {
309 await update($, hidden, () => false);
310 return;
311 }
312 if (await read($, hidden)) $.ui.toast(`New image: ${title} · /lightbox to view`);
313}
314
315/** Puts an image on the reel, makes it the current one, and prepares it in the background. */
316async function addShot($: Api, shot: NewShot, bytes?: Bytes, quiet = false): Promise<void> {
317 const id = newId();
318 adding.add(id);
319 if (bytes) inline.set(id, bytes);
320 const copy = bytes && !shot.path ? await keepCopy($, id, bytes) : undefined;
321 if (copy) copies.set(id, copy);
322 const entry: LightboxShot = {
323 id,
324 title: shot.title,
325 origin: shot.origin,
326 mime: shot.mime,
327 at: Date.now(),
328 status: "pending",
329 ...(shot.path ? { path: shot.path } : {}),
330 ...(copy ? { copy } : {}),
331 ...(shot.caption ? { caption: shot.caption } : {}),
332 ...(shot.originalWidth && shot.originalHeight ? { originalWidth: shot.originalWidth, originalHeight: shot.originalHeight } : {})
333 };
334 await update($, shots, (list) => {
335 newest = id;
336 // The same file again replaces its earlier shot, so an edited SVG or a retaken screenshot shows fresh.
337 const rest = shot.path ? list.filter((s) => s.path !== shot.path) : list;
338 const before = rest[rest.length - 1];
339 if (before && before.origin === entry.origin && entry.at - before.at <= BATCH_MS) entry.batch = before.batch ?? before.id;
340 const reel = [...rest, entry].slice(-REEL_SIZE);
341 newestIndex = reel.length - 1;
342 return reel;
343 });
344 // Evicts right on the read, with nothing awaited between, and only then stops protecting this shot: a capture
345 // running beside this one may be about to evict from an older reel that does not list it yet.
346 const list = await read($, shots);
347 evict(list);
348 adding.delete(id);
349 await sweep($);
350 // Only while it is still the newest, checked as the selection is written: a capture that finished later has
351 // already moved it on.
352 await update($, current, (i) => (newest === id ? newestIndex : (i ?? 0)));
353 $.clock.after(0, () => { void prepare($, id, "strip"); });
354 if (pixelsHint) {
355 $.ui.toast(pixelsHint);
356 pixelsHint = "";
357 }
358 if (!quiet) await announce($, shot.title);
359}
360
361/** Puts an image file on the reel, or says why it cannot. */
362async function showFile($: Api, raw: string, origin: string, caption?: string, quiet = false): Promise<string> {
363 const path = absolutePath(raw.trim(), await $.session.cwd(), (await $.env.get("HOME")) ?? "");
364 const mime = mimeForPath(path);
365 if (!mime) return `${basename(path)} is not an image the Lightbox shows (${FORMATS}).`;
366 const stat = await $.fs.stat(path).catch(() => null);
367 if (!stat || stat.kind !== "file") return `No image file at ${path}.`;
368 await addShot($, { title: basename(path), origin, path, mime, ...(caption ? { caption } : {}) }, undefined, quiet);
369 return `Showing ${basename(path)} in the Lightbox.`;
370}
371
372// Words that run another program, or only set up for one: never the program that wrote an image.
373const WRAPPERS = new Set(["cd", "pushd", "env", "sudo", "timeout", "nice", "nohup", "time", "command", "exec", "xargs"]);
374
375/** Who wrote an image file: for a shell command, the program in the part of it that names the file. */
376function originOf(call: Call, path: string): string {
377 if (call.tool === "Write" || call.tool === "Edit") return "written by Claude";
378 if (call.tool !== "Bash") return `from ${toolLabel(call.tool)}`;
379 const parts = String(call.command ?? "").split(/&&|\|\||[;|\n]/).map((part) => part.trim()).filter(Boolean);
380 const part = parts.find((p) => p.includes(basename(path))) ?? parts.find((p) => !/^(cd|pushd)\s/.test(p)) ?? "";
381 const words = part.split(/\s+/);
382 let i = 0;
383 // Skip variable assignments, wrappers and their flags and durations (`timeout 60`, `nice -n 5`).
384 while (i < words.length && (/^\w+=/.test(words[i]!) || WRAPPERS.has(words[i]!) || /^-/.test(words[i]!) || /^\d+[smhd]?$/.test(words[i]!))) i += 1;
385 const program = words[i]?.replace(/^.*\//, "");
386 return `from ${program || "a command"}`;
387}
388
389/** The directories a shell command changes into, in order: `cd dir` and `pushd dir`, quotes taken off. */
390function cdTargets(command: string): string[] {
391 return [...command.matchAll(/(?:^|[;&|(\n]\s*)(?:cd|pushd)\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)].map((m) => m[1]!.replace(/^["']|["']$/g, "")).filter((dir) => dir !== "-");
392}
393
394// Claude Code's scratch folder for a session: what Claude writes there is its own working material.
395const SCRATCH = /\/claude-\d+\/[^/]+\/[^/]+\/scratchpad\//;
396
397/** Finds the images a finished tool call read, returned or wrote, and puts them on the reel. */
398async function capture($: Api, call: Call, result: unknown, started: number): Promise<void> {
399 const r = (result ?? {}) as { deny?: unknown; isError?: unknown; result?: unknown; text?: unknown };
400 if (r.deny !== undefined || r.isError) return;
401
402 if (call.tool === "Read") {
403 const path = String(call.file_path ?? "");
404 const value = r.result as { type?: string; file?: { base64?: string; type?: string; dimensions?: { originalWidth?: number; originalHeight?: number } } } | undefined;
405 if (value?.type === "image" && value.file?.base64) {
406 const mime = value.file.type ?? "image/png";
407 const d = value.file.dimensions;
408 await addShot(
409 $,
410 { title: basename(path), origin: "read by Claude", mime, ...(path ? { path } : {}), ...(d?.originalWidth && d.originalHeight ? { originalWidth: d.originalWidth, originalHeight: d.originalHeight } : {}) },
411 { base64: value.file.base64, mime }
412 );
413 } else if (path && mimeForPath(path)) {
414 // Formats Read does not decode itself, such as HEIC, go through the file.
415 await showFile($, path, "read by Claude");
416 }
417 return;
418 }
419
420 const blocks = imageBlocks(r.result).slice(0, CAPTURE_MAX);
421 for (const block of blocks) await addShot($, { title: toolLabel(call.tool), origin: `from ${toolLabel(call.tool)}`, mime: block.mime }, block);
422 if (blocks.length) return;
423
424 // Image files the call just wrote: named in its input or output, and modified while it ran. A call that ran
425 // read-only (`ls`, `file`) wrote none, whatever it names.
426 if ((result as { isReadOnly?: true } | undefined)?.isReadOnly) return;
427 // A field that is one path is taken whole, spaces and all, and so is a quoted path in a command; the rest of the
428 // text is searched for paths.
429 const texts = [...Object.values(call).filter((v): v is string => typeof v === "string"), typeof r.text === "string" ? r.text : ""];
430 const fields = texts.filter((v) => !v.includes("\n") && Boolean(mimeForPath(v)));
431 const candidates = [...new Set([...fields, ...texts.flatMap(quotedImagePaths), ...imagePathsIn(`${JSON.stringify(call)}\n${texts.at(-1)}`)])].slice(0, 8);
432 if (!candidates.length) return;
433 const cwd = await $.session.cwd();
434 const home = (await $.env.get("HOME")) ?? "";
435 // A relative path in a command that changed directory first may be relative to where it went: the latest one
436 // that holds the file wins.
437 const bases = [cwd, ...(call.tool === "Bash" ? cdTargets(String(call.command ?? "")).map((dir) => absolutePath(dir, cwd, home)) : [])];
438 for (const candidate of candidates) {
439 let path = "";
440 let stat = null;
441 for (const base of candidate.startsWith("/") || candidate.startsWith("~/") ? [cwd] : [...bases].reverse()) {
442 path = absolutePath(candidate, base, home);
443 stat = await $.fs.stat(path).catch(() => null);
444 if (stat) break;
445 }
446 if (!stat || stat.kind !== "file" || stat.size === 0 || stat.mtimeMs < started - 2000) continue;
447 // A scratch file Claude wrote is not meant for the person; one it reads or sends still shows.
448 if (SCRATCH.test(path)) continue;
449 await addShot($, { title: basename(path), origin: originOf(call, path), path, mime: mimeForPath(path) });
450 }
451}
452
453/**
454 * A prompt queued while Claude worked keeps only its text in its own row; its images are in the conversation the
455 * next request is built from, right after that text. The row is stored before the conversation shows it, so a
456 * miss looks again a little later.
457 */
458async function queuedImages($: Api, text: string, labeled: boolean): Promise<Bytes[]> {
459 // A prompt that names an image is looked up until it shows; one that names none, once.
460 for (const wait of labeled ? [0, 250, 1000] : [0]) {
461 if (wait) await new Promise<void>((resolve) => { $.clock.after(wait, () => resolve()); });
462 const found = imagesAfterText(await $.session.messages({ as: "api" }), text);
463 if (found.length) return found;
464 }
465 if (!labeled) return [];
466 $.ui.log("lightbox: a queued prompt named an image the conversation does not hold", { to: "debug" });
467 return [];
468}
469
470/**
471 * Images the person put in a prompt: its row carries each as an image block, whether pasted from the clipboard
472 * or dragged in as a file (which Claude Code also notes as `[Image: source: /path]`, naming it). A prompt typed
473 * while Claude works arrives as a `queued_command` row instead, delivered into the running turn.
474 */
475async function capturePasted($: Api, row: Row): Promise<void> {
476 const queued = row.message.type === "attachment" && row.message.name === "queued_command";
477 if (row.agentId || (row.message.type !== "user" && !queued) || (row.origin.kind !== "composer" && row.origin.kind !== "bridge")) return;
478 const texts = row.message.content.flatMap((b) => ((b as { type?: string }).type === "text" ? [String((b as { text?: unknown }).text ?? "")] : []));
479 const text = texts.join("\n");
480 // A queued prompt names its pasted images `[Image #N]` when they came from the clipboard; one from Remote Control
481 // or with a dragged-in file may carry its images with no label, so then its text is looked up whole.
482 const labeled = texts.find((t) => /\[Image #\d+\]/.test(t));
483 const marked = labeled ?? texts.findLast((t) => t.trim());
484 const blocks = (queued ? (marked ? await queuedImages($, marked, Boolean(labeled)) : []) : imageBlocks(row.message.content)).slice(0, CAPTURE_MAX);
485 const paths = pastedImagePaths(text);
486 // A message with no image means the person has moved on: the strip folds to one line until the next image.
487 if (!blocks.length && !paths.length) {
488 await update($, folded, () => true);
489 return;
490 }
491 // The transcript shows each pasted image as `[Image #N]`; the strip names it the same, so the two match.
492 const labels = [...text.matchAll(/\[Image #(\d+)\]/g)].map((m) => `Image #${m[1]}`);
493 const cwd = await $.session.cwd();
494 const home = (await $.env.get("HOME")) ?? "";
495 for (const [i, block] of blocks.entries()) {
496 const path = paths[i] ? absolutePath(paths[i], cwd, home) : undefined;
497 const title = labels[i] ?? (path ? basename(path) : blocks.length > 1 ? `Pasted image ${i + 1}` : "Pasted image");
498 await addShot($, { title, origin: "pasted by you", mime: block.mime, ...(path ? { path } : {}) }, block);
499 }
500 // A file the model could not take as an image block (a HEIC, say) still shows, from the file.
501 for (const path of paths.slice(blocks.length, CAPTURE_MAX)) await showFile($, path, "pasted by you");
502}
503
504async function removeShot($: Api, id: string): Promise<void> {
505 let length = 0;
506 await update($, shots, (list) => {
507 const rest = list.filter((s) => s.id !== id);
508 length = rest.length;
509 return rest;
510 });
511 await update($, current, (i) => Math.max(0, Math.min(i ?? 0, length - 1)));
512 pixels.delete(id);
513 inline.delete(id);
514 const copy = copies.get(id);
515 if (copy) {
516 copies.delete(id);
517 gone.push(copy);
518 await sweep($);
519 }
520}
521
522async function openShot($: Api, shot: LightboxShot): Promise<void> {
523 const file = shot.path ?? shot.copy;
524 if (file) await $.process.run(["open", file]);
525}
526
527/**
528 * Whether Claude Code draws real pixels here, unless the option says otherwise. Claude Code asks the terminal
529 * and draws only for kitty or ghostty by name, so inside a multiplexer that renders kitty graphics (herdr names
530 * itself libghostty) it needs CLAUDE_CODE_FORCE_TERMINAL_IMAGES; without it, a multiplexer means cells.
531 */
532async function choosePixels($: Api): Promise<boolean> {
533 if (rendererOption === "pixels") return true;
534 if (rendererOption === "cells") return false;
535 // Claude Code reads the variable as on only for these values; 0, false or off leave pixels off.
536 if (/^(1|true|yes|on)$/i.test(((await $.env.get("CLAUDE_CODE_FORCE_TERMINAL_IMAGES")) ?? "").trim())) return true;
537 const term = (await $.env.get("TERM")) ?? "";
538 const program = (await $.env.get("TERM_PROGRAM")) ?? "";
539 const kitty = Boolean(await $.env.get("KITTY_WINDOW_ID"));
540 const multiplexed = Boolean((await $.env.get("HERDR_ENV")) || (await $.env.get("TMUX")) || (await $.env.get("ZELLIJ")) || (await $.env.get("STY")));
541 return !multiplexed && (/kitty|ghostty/i.test(term) || kitty || /^(ghostty|kitty)$/i.test(program));
542}
543
544const clampIndex = (i: number | undefined, length: number) => Math.max(0, Math.min(i ?? 0, length - 1));
545
546/** When it arrived, its original size and its format. */
547function details(shot: LightboxShot): string {
548 const sizeLabel = shot.originalWidth && shot.originalHeight ? `${shot.originalWidth}×${shot.originalHeight}` : shot.width ? `${shot.width}×${shot.height}` : "";
549 return [ago(Date.now() - shot.at), sizeLabel, formatLabel(shot.mime)].filter(Boolean).join(" · ");
550}
551
552const metaLine = (shot: LightboxShot) => `${shot.origin} · ${details(shot)}`;
553
554function originColor(origin: string): string | undefined {
555 if (/\byou$/.test(origin)) return YOURS;
556 if (/\bClaude$/.test(origin)) return CLAUDES;
557 return undefined;
558}
559
560export const register: Register = (on, options) => {
561 autoOpen = options.autoOpen !== false;
562 rendererOption = String(options.renderer ?? "auto");
563 const aspect = Number(options.cellAspect);
564 cellAspect = aspect >= 1 && aspect <= 4 ? aspect : CELL_ASPECT;
565 // An explicit choice holds from the start; "auto" is settled at session start, from the terminal's environment.
566 drawsPixels = rendererOption === "pixels";
567
568 on("session.start", async ($, e, next) => {
569 const result = await next(e);
570 const system = await $.process.run(["uname", "-s"]).catch(() => null);
571 const remote = (await $.env.get("SSH_CONNECTION")) || (await $.env.get("SSH_TTY"));
572 // Opening a file in Preview or Finder only means something on the Mac the person is looking at.
573 canOpen = system?.stdout.trim() === "Darwin" && !remote;
574 drawsPixels = await choosePixels($);
575 cacheDir = `${((await $.env.get("TMPDIR")) ?? "/tmp").replace(/\/$/, "")}/lightbox/${await $.session.id()}`;
576 // After a reload the module holds no bytes: an image with neither a file nor a private copy cannot be drawn
577 // again, so it leaves the reel.
578 if (freshModule) {
579 freshModule = false;
580 const kept = await update($, shots, (list) => list.filter((s) => s.path || s.copy));
581 for (const s of kept) if (s.copy) copies.set(s.id, s.copy);
582 await update($, current, (i) => clampIndex(i, kept.length));
583 }
584 if (!drawsPixels && rendererOption === "auto" && (await $.env.get("HERDR_ENV")) && ((await $.env.get("GHOSTTY_RESOURCES_DIR")) || (await $.env.get("KITTY_WINDOW_ID")))) {
585 pixelsHint = "Lightbox draws blocks here: this shell has no CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. Set it, or open a new pane, and restart Claude for real pixels.";
586 $.ui.log(`lightbox: ${pixelsHint}`, { to: "debug" });
587 }
588 // Keeps "2 min ago" true while the strip sits there.
589 $.clock.every(60_000, () => $.ui.invalidate("ui.render"));
590 if (!autoOpen && !(await read($, shots)).length) await update($, hidden, () => true);
591 await $.tool.register({
592 name: "show",
593 description: `Show the user an image file in the Lightbox, the strip above their prompt. Use it whenever you want the user to see an image: a screenshot you took, an image you generated or edited, a diagram you rendered, or an image file you found. Accepts ${FORMATS}. Images you open with the Read tool, and images tools return, appear there by themselves.`,
594 inputSchema: {
595 type: "object",
596 properties: {
597 path: { type: "string", description: "The image file: an absolute path, ~/..., or a path relative to the working directory" },
598 caption: { type: "string", description: "One short line shown beside the image" }
599 },
600 required: ["path"]
601 }
602 });
603 try {
604 await $.command.register({ name: "lightbox", description: "Show or hide the Lightbox strip above the prompt: the images you pasted and Claude read, received or created", argumentHint: "[image path | view | clear]", immediate: true });
605 } catch (err) {
606 $.ui.log(`lightbox: /lightbox not registered: ${message(err)}`, { to: "debug" });
607 }
608 return result;
609 });
610
611 // Images pasted into a prompt, typed at an idle prompt (door `prompt`) or queued while Claude works and
612 // delivered into the running turn (door `delivery`). Captured once the row is stored, off the append's path,
613 // so the prompt is not held up.
614 for (const door of ["prompt", "delivery"] as const) {
615 on("session.append", { door }, async ($, e, next) => {
616 const result = await next(e);
617 $.clock.after(0, () => {
618 void capturePasted($, e as unknown as Row).catch((err) => $.ui.log(`lightbox: ${message(err)}`, { to: "debug" }));
619 });
620 return result;
621 });
622 }
623
624 on("tool.call", async ($, e, next) => {
625 const call = e as unknown as Call;
626 if (call.tool === SHOW_TOOL) {
627 // One line: the strip gives a caption one row.
628 const caption = typeof call.caption === "string" ? call.caption.replace(/\s+/g, " ").trim().slice(0, 200) || undefined : undefined;
629 return { result: await showFile($, String(call.path ?? ""), "sent by Claude", caption) };
630 }
631 const started = Date.now();
632 const result = await next(e);
633 try {
634 await capture($, call, result, started);
635 } catch (err) {
636 $.ui.log(`lightbox: ${message(err)}`, { to: "debug" });
637 }
638 return result;
639 });
640
641 on("command.run", { command: "lightbox" }, async ($, e) => {
642 const arg = String(e.args ?? "").trim();
643 if (arg === "clear") {
644 await update($, shots, () => []);
645 await update($, current, () => 0);
646 pixels.clear();
647 inline.clear();
648 cellCache.clear();
649 gone.push(...copies.values());
650 copies.clear();
651 await sweep($);
652 return { text: "Lightbox cleared." };
653 }
654 if (arg === "view") {
655 await $.ui.open({ id: PANE, title: "Lightbox", focus: true });
656 return {};
657 }
658 if (arg) {
659 const said = await showFile($, arg, "opened by you", undefined, true);
660 if (!said.startsWith("Showing")) return { text: said };
661 await update($, hidden, () => false);
662 await update($, folded, () => false);
663 return {};
664 }
665 if (!(await read($, shots)).length) return { text: `No images yet. Pasted images, images Claude reads or sends, and /lightbox <path> (${FORMATS}) appear above the prompt.` };
666 const open = !(await read($, hidden)) && !(await read($, folded));
667 await update($, hidden, () => open);
668 await update($, folded, () => false);
669 return {};
670 });
671
672 on("ui.close", { id: PANE }, async ($, e, next) => {
673 const result = await next(e);
674 $.ui.invalidate("ui.render");
675 return result;
676 });
677
678 // The strip above the prompt: the current image and the others that arrived with it, small, side by side,
679 // with the current one's name, where it came from, and the keys.
680 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
681 if (e.props.hasSurvey || (await read($, hidden))) return next(e);
682 const list = await read($, shots);
683 if (!list.length) return next(e);
684 // While the larger pane shows, the strip would only repeat it.
685 if ((await $.ui.panes()).some((pane) => pane.id === PANE && pane.isShown)) return next(e);
686 const { Box, Text, Button } = $.ui.resolve(e);
687 // What the mods after this one draw in the band (statusline-hud's rows) goes under the strip, so it stays
688 // closest to the prompt whichever mod runs first; the strip fits in the rows it leaves.
689 const rest = await next(e);
690 const withRest = (strip: RenderChildren) => (
691 <Box flexDirection="column">
692 {strip}
693 {rest}
694 </Box>
695 );
696 const elements = $.ui.resolve(e) as unknown as { Image?: ElementConstructor<ImageProps>; Raster?: ElementConstructor<RasterProps> };
697 const Image = e.surface === "terminal" && drawsPixels ? elements.Image : undefined;
698 const Raster = e.surface === "terminal" && !drawsPixels ? elements.Raster : undefined;
699
700 const index = clampIndex(await read($, current), list.length);
701 const shot = list[index]!;
702
703 // The batch the current image arrived in, and the window of it that fits.
704 const batchOf = (s: LightboxShot) => s.batch ?? s.id;
705 let first = index;
706 let last = index;
707 while (first > 0 && batchOf(list[first - 1]!) === batchOf(shot)) first -= 1;
708 while (last < list.length - 1 && batchOf(list[last + 1]!) === batchOf(shot)) last += 1;
709 const from = Math.max(first, Math.min(index - Math.floor(BATCH_SHOWN / 2), last - BATCH_SHOWN + 1));
710 const shown = list.slice(from, Math.min(last + 1, from + BATCH_SHOWN));
711 const before = from - first;
712 const after = last + 1 - (from + shown.length);
713
714 // The band gets the rows the bottom slot has left above the prompt, fewer while a list of running agents
715 // sits there too, and scrolls whatever is taller. The strip always fits them whole instead: smaller pictures
716 // first, then fewer words and keys, then a single line.
717 const rows = Math.max(1, e.props.maxRows - rowsOf(rest));
718 const maxRows = Math.max(1, Math.min(STRIP_ROWS, rows - 2));
719 // The pictures share about three fifths of the width, each in its frame; the words take the rest.
720 const budget = Math.floor(e.props.bodyColumns * 0.6) - (shown.length - 1);
721 const each = Math.max(6, Math.min(STRIP_MAX_COLUMNS, Math.floor(budget / shown.length) - 2));
722 const boxOf = (s: LightboxShot) => (s.width && s.height ? fit(s.width, s.height, each, maxRows, cellAspect) : { columns: Math.min(each, Math.round(3 * cellAspect)), rows: Math.min(3, maxRows) });
723 const counter = (n: number) => (n > 0 ? String(n).length + 2 : 0);
724 const picturesWidth = shown.reduce((sum, s) => sum + boxOf(s).columns + 2, 0) + (shown.length - 1) + counter(before) + counter(after);
725 // The words' column: the band less its padding, the pictures, the gap and the room kept for the collapse mark.
726 const wordsWidth = e.props.bodyColumns - 2 - picturesWidth - 2 - 4;
727 const cramped = rows < 4 || wordsWidth < 16;
728
729 const frame = (s: LightboxShot) => {
730 const strip = pixels.get(s.id)?.strip;
731 if (!strip && s.status !== "failed") $.clock.after(0, () => { void prepare($, s.id, "strip"); });
732 const box = boxOf(s);
733 let view;
734 if (s.status === "failed") view = <Text color="red">✕</Text>;
735 else if (strip && Image) view = <Image key={`s-${s.id}`} source={{ png: strip }} columns={box.columns} rows={box.rows} alt={`${s.title} (image)`} />;
736 else if (strip && Raster) {
737 const cells = cellCache.get(`${s.id}:${box.columns}x${box.rows}`);
738 if (!cells) $.clock.after(0, () => { void renderCells($, s.id, box.columns, box.rows); });
739 view = cells ? <Raster key={`s-${s.id}`} columns={box.columns} rows={box.rows} cells={cells} /> : <Text dimColor>…</Text>;
740 } else view = <Text dimColor>…</Text>;
741 // The frame keeps a dark picture from melting into a dark terminal, in the color of who sent it; in a batch,
742 // only the current one's frame is lit.
743 const lit = s.id === shot.id && shown.length > 1;
744 const color = s.id === shot.id ? (originColor(s.origin) ?? "gray") : "gray";
745 return (
746 <Box key={`f-${s.id}`} borderStyle="round" borderColor={color} borderDimColor={!lit} width={box.columns + 2} height={box.rows + 2} flexShrink={0}>
747 {view}
748 </Box>
749 );
750 };
751
752 const step = (delta: number) => () => { void update($, current, (i) => ((((i ?? 0) + delta) % list.length) + list.length) % list.length); };
753 const openPane = () => { void $.ui.open({ id: PANE, title: "Lightbox", focus: true }).then(() => $.ui.invalidate("ui.render")); };
754 const tint = originColor(shot.origin);
755 const count = last + 1 - first;
756
757 // Folded: one line, its pictures a row tall, until the next image or the person opens it. Also where the band
758 // has no room for more, and then the larger pane is the way to see it.
759 const isFolded = await read($, folded);
760 if (isFolded || cramped) {
761 const tiny = (s: LightboxShot) => {
762 const strip = pixels.get(s.id)?.strip;
763 if (!strip && s.status !== "failed") $.clock.after(0, () => { void prepare($, s.id, "strip"); });
764 if (!strip || !s.width || !s.height) return null;
765 const size = fit(s.width, s.height, 8, 1, cellAspect);
766 if (Image) return <Image key={`t-${s.id}`} source={{ png: strip }} columns={size.columns} rows={1} alt={s.title} />;
767 if (!Raster) return null;
768 const cells = cellCache.get(`${s.id}:${size.columns}x1`);
769 if (!cells) $.clock.after(0, () => { void renderCells($, s.id, size.columns, 1); });
770 return cells ? <Raster key={`t-${s.id}`} columns={size.columns} rows={1} cells={cells} /> : null;
771 };
772 // The keys and a dozen cells of name come first; tiny pictures fill what is left, none on a narrow band. Each key
773 // is a framed `[ label ]`, four cells wider than its label, unless the band is too narrow for frames: then the
774 // labels alone, as before.
775 const isFramed = e.props.bodyColumns >= 2 + 4 + "larger".length + 4 + 1 + "hide".length + 4 + 1 + 12 + 5;
776 const frameWidth = isFramed ? 4 : 0;
777 const keysWidth = (isFolded ? "expand".length : "larger".length) + frameWidth + 1 + "hide".length + frameWidth;
778 let room = e.props.bodyColumns - 2 - 4 - keysWidth - 1 - 12;
779 const fitting = shown.filter((s) => {
780 const width = s.width && s.height ? fit(s.width, s.height, 8, 1, cellAspect).columns + 1 : 0;
781 if (width > room) { room = 0; return false; }
782 room -= width;
783 return true;
784 });
785 return withRest(
786 <Box flexDirection="row" paddingX={1} paddingRight={4} columnGap={1}>
787 {fitting.map(tiny)}
788 <Text bold wrap="truncate-middle">{shot.title}</Text>
789 {count > 1 ? <Text dimColor wrap="truncate-end">+{count - 1}</Text> : null}
790 {tint ? <Text color={tint} wrap="truncate-end">{shot.origin}</Text> : <Text dimColor wrap="truncate-end">{shot.origin}</Text>}
791 <Text dimColor wrap="truncate-end">· {ago(Date.now() - shot.at)}</Text>
792 <Box flexGrow={1} />
793 {isFolded ? (isFramed ? <Button key="expand" variant="primary" label="expand" onPress={() => { void update($, folded, () => false); }} /> : <Button key="expand" plain dimColor label="expand" onPress={() => { void update($, folded, () => false); }} />) : null}
794 {isFolded ? null : isFramed ? <Button key="view" variant="primary" label="larger" onPress={openPane} /> : <Button key="view" plain dimColor label="larger" onPress={openPane} />}
795 {isFramed ? <Button key="hide" variant="secondary" label="hide" onPress={() => { void update($, hidden, () => true); }} /> : <Button key="hide" plain dimColor label="hide" onPress={() => { void update($, hidden, () => true); }} />}
796 </Box>
797 );
798 }
799
800 // The reel at a glance: a dot per image in its sender's color, the current one filled, batches apart.
801 const reel =
802 list.length > DOTS ? (
803 <Text dimColor>{index + 1}/{list.length}</Text>
804 ) : list.length > 1 ? (
805 <Box flexDirection="row">
806 {list.map((s, i) => {
807 const color = originColor(s.origin);
808 const dot = `${i > 0 && batchOf(list[i - 1]!) !== batchOf(s) ? " " : ""}${i === index ? "●" : "○"}`;
809 return color ? <Text key={`d-${s.id}`} color={color} dimColor={i !== index}>{dot}</Text> : <Text key={`d-${s.id}`} dimColor>{dot}</Text>;
810 })}
811 </Box>
812 ) : null;
813
814 // The words' rows: a row above them, level with the picture's top, only when the band has rows to spare; then
815 // the name and the details, a note when there is room, and the keys in what is left, the least needed dropped
816 // first so they fit without wrapping past it.
817 const lead = rows >= 6 ? 1 : 0;
818 const spare = rows - lead - 2;
819 const noteText = shot.status === "failed" ? `Can't show it: ${shot.note ?? "unknown error"}` : shot.caption;
820 const note = noteText && spare >= 2 ? noteText : undefined;
821 const keyRows = spare - (note ? 1 : 0);
822 type Key = { id: string; width: number; node: RenderChildren };
823 // Buttons that look like buttons: a rounded outline in the key's tone, brighter under the pointer, when there are
824 // three rows for a line of them; otherwise one row of framed `[ label ]` keys. Both are four cells wider than the
825 // label.
826 const isOutlined = keyRows >= 3;
827 const keyLines = isOutlined ? Math.floor(keyRows / 3) : keyRows;
828 const button = (id: string, label: string, onPress: () => void, tone: Tone = "neutral"): Key => ({
829 id,
830 width: label.length + 4,
831 node: isOutlined
832 ? outlined(Box, Button, id, label, onPress, tone)
833 : <Button key={id} variant={tone === "primary" ? "primary" : "secondary"} label={label} onPress={onPress} />
834 });
835 let keys: Key[] = [
836 ...(list.length > 1 ? [button("prev", "‹ prev", step(-1)), button("next", "next ›", step(1))] : []),
837 button("view", "larger", openPane, "primary"),
838 ...(canOpen && (shot.path || shot.copy) ? [button("open", "open", () => { void openShot($, shot); })] : []),
839 button("fold", "fold", () => { void update($, folded, () => true); }),
840 button("hide", "hide", () => { void update($, hidden, () => true); })
841 ];
842 const rowsFor = (items: Key[]) => {
843 let lines = 1;
844 let used = -1;
845 for (const item of items) {
846 if (used + 1 + item.width > wordsWidth && used >= 0) { lines += 1; used = -1; }
847 used += 1 + item.width;
848 }
849 return lines;
850 };
851 for (const drop of ["fold", "open", "prev", "next", "view"]) {
852 if (rowsFor(keys) <= keyLines) break;
853 keys = keys.filter((k) => k.id !== drop);
854 }
855
856 return withRest(
857 <Box flexDirection="row" paddingX={1} columnGap={2}>
858 <Box flexDirection="row" columnGap={1} flexShrink={0}>
859 {before > 0 ? <Box paddingTop={1}><Text dimColor>+{before}</Text></Box> : null}
860 {shown.map(frame)}
861 {after > 0 ? <Box paddingTop={1}><Text dimColor>+{after}</Text></Box> : null}
862 </Box>
863 {/* Level with the picture's top, and clear of the collapse mark in the band's top right corner. */}
864 <Box flexDirection="column" flexGrow={1} flexShrink={1} paddingTop={lead} paddingRight={4}>
865 <Box flexDirection="row" columnGap={2} height={1}>
866 <Text bold wrap="truncate-middle">{shot.title}</Text>
867 {reel}
868 </Box>
869 <Box flexDirection="row" height={1}>
870 <Text wrap="truncate-end">
871 {tint ? <Text color={tint}>{shot.origin}</Text> : <Text dimColor>{shot.origin}</Text>}
872 <Text dimColor> · {details(shot)}</Text>
873 </Text>
874 </Box>
875 {note ? (shot.status === "failed" ? <Text color="red" wrap="truncate-end">{note}</Text> : <Text italic wrap="truncate-end">{note}</Text>) : null}
876 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
877 {keys.map((k) => k.node)}
878 </Box>
879 </Box>
880 </Box>
881 );
882 });
883
884 // The pane, opened from the strip or `/lightbox view`: the current image as large as the pane allows.
885 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
886 const { Box, Text, Button } = $.ui.resolve(e);
887 const elements = $.ui.resolve(e) as unknown as { Image?: ElementConstructor<ImageProps>; Raster?: ElementConstructor<RasterProps> };
888 const Image = e.surface === "terminal" && drawsPixels ? elements.Image : undefined;
889 const Raster = e.surface === "terminal" && !drawsPixels ? elements.Raster : undefined;
890 const list = await read($, shots);
891 const width = Math.max(10, e.props.bodyColumns - 2);
892
893 if (!list.length) {
894 return (
895 <Box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
896 <Text bold>Lightbox</Text>
897 <Text dimColor>Images from this session appear here: whatever Claude reads, captures, generates or sends you, and what you paste.</Text>
898 <Text dimColor>/lightbox ~/Pictures/photo.heic opens any {FORMATS} file.</Text>
899 </Box>
900 );
901 }
902
903 const index = clampIndex(await read($, current), list.length);
904 const shot = list[index]!;
905 const picture = pixels.get(shot.id);
906 if (!picture?.strip && shot.status !== "failed") $.clock.after(0, () => { void prepare($, shot.id, "strip"); });
907 // Real pixels want the larger picture; until it is ready the strip's stands in. Cells need no more than the strip's.
908 if (Image && picture?.strip && !picture.full) $.clock.after(0, () => { void prepare($, shot.id, "full"); });
909
910 const viewportRows = e.viewport?.rows ?? 40;
911 // A docked pane is shorter than the screen (the prompt and status sit below it): size to its body.
912 const bodyRows = e.props.scroll?.bodyRows ?? 0;
913 const hasStrip = list.length > 1;
914 // The footer: a rule, then the outlined keys, three rows for each line they wrap onto at this width.
915 const footerKeys = [
916 ...(hasStrip ? ["‹ prev", "next ›"] : []),
917 ...(canOpen && (shot.path || shot.copy) ? ["open"] : []),
918 ...(canOpen && shot.path ? ["show in Finder"] : []),
919 ...(shot.path ? ["copy path"] : []),
920 "remove"
921 ];
922 let footerLines = 1;
923 let used = -1;
924 for (const label of footerKeys) {
925 if (used + 1 + label.length + 4 > width && used >= 0) { footerLines += 1; used = -1; }
926 used += 1 + label.length + 4;
927 }
928 const chrome = 2 + 2 + (shot.caption ? 2 : 0) + (hasStrip ? THUMB_ROWS + 3 : 0) + 2 + 3 * footerLines;
929 const maxRows =
930 e.props.placement === "dock"
931 ? Math.max(6, (bodyRows > 0 ? bodyRows : viewportRows - 4) - chrome)
932 : Math.max(6, Math.min(20, Math.floor(viewportRows * 0.5)));
933 const box = shot.width && shot.height ? fit(shot.width, shot.height, width, maxRows, cellAspect) : null;
934
935 let view;
936 const source = picture?.full ?? picture?.strip;
937 if (shot.status === "failed") {
938 view = <Text color="red">Can't show this image: {shot.note ?? "unknown error"}</Text>;
939 } else if (source && box && Image) {
940 view = <Image key="main" source={{ png: source }} columns={box.columns} rows={box.rows} alt={`${shot.title} (image)`} />;
941 } else if (source && box && Raster) {
942 const cells = cellCache.get(`${shot.id}:${box.columns}x${box.rows}`);
943 if (!cells) $.clock.after(0, () => { void renderCells($, shot.id, box.columns, box.rows); });
944 view = cells ? <Raster key="main" columns={box.columns} rows={box.rows} cells={cells} /> : <Text dimColor>Drawing {shot.title}…</Text>;
945 } else if (source && !Image && !Raster) {
946 view = <Text dimColor>This surface does not draw images. Open it instead.</Text>;
947 } else {
948 view = <Text dimColor>Preparing {shot.title}…</Text>;
949 }
950
951 const slots = Math.max(1, Math.min(5, Math.floor((width + 1) / (THUMB_COLUMNS + 3))));
952 const first = Math.max(0, Math.min(index - Math.floor(slots / 2), list.length - slots));
953 const strip = list.slice(first, first + slots);
954 const step = (delta: number) => () => { void update($, current, (i) => ((((i ?? 0) + delta) % list.length) + list.length) % list.length); };
955
956 const thumbnail = (s: LightboxShot) => {
957 const thumb = pixels.get(s.id)?.strip;
958 if (!thumb && s.status !== "failed") $.clock.after(0, () => { void prepare($, s.id, "strip"); });
959 if (!thumb || !s.width || !s.height) return <Text dimColor>…</Text>;
960 const size = fit(s.width, s.height, THUMB_COLUMNS, THUMB_ROWS, cellAspect);
961 if (Image) return <Image key={`i-${s.id}`} source={{ png: thumb }} columns={size.columns} rows={size.rows} alt={s.title} />;
962 if (!Raster) return <Text dimColor wrap="truncate-end">{s.title}</Text>;
963 const cells = cellCache.get(`${s.id}:${size.columns}x${size.rows}`);
964 if (!cells) $.clock.after(0, () => { void renderCells($, s.id, size.columns, size.rows); });
965 return cells ? <Raster key={`r-${s.id}`} columns={size.columns} rows={size.rows} cells={cells} /> : <Text dimColor>…</Text>;
966 };
967
968 return (
969 <Box flexDirection="column" paddingX={1}>
970 {/* Clear of the close mark Claude Code draws in the pane's top right corner. */}
971 <Box flexDirection="row" justifyContent="space-between" paddingRight={2}>
972 <Text bold wrap="truncate-middle">{shot.title}</Text>
973 <Text dimColor>{index + 1} of {list.length}</Text>
974 </Box>
975 <Text dimColor wrap="truncate-end">{metaLine(shot)}</Text>
976 <Box flexDirection="row" justifyContent="center" marginTop={1}>
977 {view}
978 </Box>
979 {shot.caption ? <Box marginTop={1}><Text italic>{shot.caption}</Text></Box> : null}
980 {hasStrip ? (
981 <Box flexDirection="row" justifyContent="center" gap={1} marginTop={1}>
982 {strip.map((s) => (
983 <Box
984 key={`t-${s.id}`}
985 borderStyle="single"
986 borderColor={s.id === shot.id ? "cyan" : "gray"}
987 borderDimColor={s.id !== shot.id}
988 width={THUMB_COLUMNS + 2}
989 height={THUMB_ROWS + 2}
990 justifyContent="center"
991 alignItems="center"
992 >
993 {thumbnail(s)}
994 </Box>
995 ))}
996 </Box>
997 ) : null}
998 <Box marginTop={1}>
999 <Text dimColor>{"─".repeat(width)}</Text>
1000 </Box>
1001 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
1002 {list.length > 1 ? outlined(Box, Button, "prev", "‹ prev", step(-1)) : null}
1003 {list.length > 1 ? outlined(Box, Button, "next", "next ›", step(1)) : null}
1004 {canOpen && (shot.path || shot.copy) ? outlined(Box, Button, "open", "open", () => { void openShot($, shot); }, "primary") : null}
1005 {canOpen && shot.path ? outlined(Box, Button, "reveal", "show in Finder", () => { void $.process.run(["open", "-R", shot.path!]); }) : null}
1006 {shot.path ? outlined(Box, Button, "copy", "copy path", () => { void $.ui.copy({ text: shot.path!, surface: e.surface }); }) : null}
1007 {outlined(Box, Button, "remove", "remove", () => { void removeShot($, shot.id); }, "danger")}
1008 </Box>
1009 </Box>
1010 );
1011 });
1012};
1013hooks/cells.ts 131 lines1// Draws a picture with text: each terminal cell shows a Unicode quadrant block and two colors, covering 2×2
2// pixels. It works in any terminal and over SSH, where the kitty graphics protocol does not reach.
3
4const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5const LOOKUP = (() => {
6 const table = new Int16Array(128).fill(-1);
7 for (let i = 0; i < ALPHABET.length; i += 1) table[ALPHABET.charCodeAt(i)] = i;
8 return table;
9})();
10
11export function decodeBase64(text: string): Uint8Array {
12 const out = new Uint8Array(Math.floor((text.length * 3) / 4) + 3);
13 let buffer = 0;
14 let bits = 0;
15 let o = 0;
16 for (let i = 0; i < text.length; i += 1) {
17 const code = text.charCodeAt(i);
18 const value = code < 128 ? (LOOKUP[code] ?? -1) : -1;
19 if (value < 0) continue;
20 buffer = ((buffer << 6) | value) & 0xffffff;
21 bits += 6;
22 if (bits >= 8) {
23 bits -= 8;
24 out[o++] = (buffer >> bits) & 0xff;
25 }
26 }
27 return out.subarray(0, o);
28}
29
30export function encodeBase64(bytes: Uint8Array): string {
31 const parts: string[] = [];
32 for (let i = 0; i < bytes.length; i += 3) {
33 const a = bytes[i] ?? 0;
34 const b = bytes[i + 1];
35 const c = bytes[i + 2];
36 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
37 parts.push(
38 (ALPHABET[(n >> 18) & 63] ?? "") +
39 (ALPHABET[(n >> 12) & 63] ?? "") +
40 (b === undefined ? "=" : (ALPHABET[(n >> 6) & 63] ?? "")) +
41 (c === undefined ? "=" : (ALPHABET[n & 63] ?? ""))
42 );
43 }
44 return parts.join("");
45}
46
47/** A picture as colors `0xRRGGBB`, read by pixel. */
48export type Pixels = { width: number; height: number; at: (x: number, y: number) => number };
49
50/** Reads an uncompressed 24- or 32-bit BMP, as ImageMagick (`bmp3:`) and sips write them. */
51export function readBmp(bytes: Uint8Array): Pixels | null {
52 if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null;
53 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54 const offset = view.getUint32(10, true);
55 const width = view.getInt32(18, true);
56 const rawHeight = view.getInt32(22, true);
57 const bpp = view.getUint16(28, true);
58 if (width <= 0 || rawHeight === 0 || (bpp !== 24 && bpp !== 32)) return null;
59 const height = Math.abs(rawHeight);
60 const stride = Math.floor((bpp * width + 31) / 32) * 4;
61 const step = bpp / 8;
62 const at = (x: number, y: number) => {
63 const cx = Math.min(width - 1, Math.max(0, x));
64 const cy = Math.min(height - 1, Math.max(0, y));
65 const row = rawHeight < 0 ? cy : height - 1 - cy;
66 const i = offset + row * stride + cx * step;
67 return ((bytes[i + 2] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i] ?? 0);
68 };
69 return { width, height, at };
70}
71
72// The quadrant block for each set of lit quarters: bit 0 top left, 1 top right, 2 bottom left, 3 bottom right.
73const QUADRANTS = [0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588];
74
75const red = (c: number) => (c >> 16) & 0xff;
76const green = (c: number) => (c >> 8) & 0xff;
77const blue = (c: number) => c & 0xff;
78
79/**
80 * The cells of a `Raster` `columns` wide and `rows` tall, from a picture of `columns * 2` by `rows * 2`
81 * pixels: each cell takes the quadrant pattern and the two colors closest to its four pixels.
82 */
83export function quadrantCells(picture: Pixels, columns: number, rows: number): string {
84 const out = new Uint8Array(columns * rows * 12);
85 const view = new DataView(out.buffer);
86 const quad = [0, 0, 0, 0];
87 for (let row = 0; row < rows; row += 1) {
88 for (let col = 0; col < columns; col += 1) {
89 quad[0] = picture.at(col * 2, row * 2);
90 quad[1] = picture.at(col * 2 + 1, row * 2);
91 quad[2] = picture.at(col * 2, row * 2 + 1);
92 quad[3] = picture.at(col * 2 + 1, row * 2 + 1);
93 let bestMask = 15;
94 let bestFg = 0;
95 let bestBg = 0;
96 let bestError = Infinity;
97 for (let mask = 1; mask <= 15; mask += 1) {
98 let litR = 0, litG = 0, litB = 0, darkR = 0, darkG = 0, darkB = 0, lit = 0;
99 for (let q = 0; q < 4; q += 1) {
100 const c = quad[q] ?? 0;
101 if ((mask >> q) & 1) {
102 litR += red(c); litG += green(c); litB += blue(c); lit += 1;
103 } else {
104 darkR += red(c); darkG += green(c); darkB += blue(c);
105 }
106 }
107 const dark = 4 - lit;
108 const fg = [Math.round(litR / lit), Math.round(litG / lit), Math.round(litB / lit)];
109 const bg = dark ? [Math.round(darkR / dark), Math.round(darkG / dark), Math.round(darkB / dark)] : fg;
110 let error = 0;
111 for (let q = 0; q < 4; q += 1) {
112 const c = quad[q] ?? 0;
113 const target = (mask >> q) & 1 ? fg : bg;
114 error += (red(c) - (target[0] ?? 0)) ** 2 + (green(c) - (target[1] ?? 0)) ** 2 + (blue(c) - (target[2] ?? 0)) ** 2;
115 }
116 if (error < bestError) {
117 bestError = error;
118 bestMask = mask;
119 bestFg = ((fg[0] ?? 0) << 16) | ((fg[1] ?? 0) << 8) | (fg[2] ?? 0);
120 bestBg = ((bg[0] ?? 0) << 16) | ((bg[1] ?? 0) << 8) | (bg[2] ?? 0);
121 }
122 }
123 const i = (row * columns + col) * 12;
124 view.setUint32(i, QUADRANTS[bestMask] ?? 0x2588, true);
125 view.setUint32(i + 4, bestFg, true);
126 view.setUint32(i + 8, bestBg, true);
127 }
128 }
129 return encodeBase64(out);
130}
131hooks/images.ts 200 lines1// Image facts the Lightbox needs, with no access to anything outside: formats, sizes, paths, layout.
2
3/** File extensions the Lightbox shows, and the MIME type each one arrives as. */
4const MIME_BY_EXTENSION: Record<string, string> = {
5 png: "image/png",
6 jpg: "image/jpeg",
7 jpeg: "image/jpeg",
8 gif: "image/gif",
9 webp: "image/webp",
10 heic: "image/heic",
11 heif: "image/heif",
12 avif: "image/avif",
13 tif: "image/tiff",
14 tiff: "image/tiff",
15 bmp: "image/bmp",
16 svg: "image/svg+xml"
17};
18
19const EXTENSIONS = Object.keys(MIME_BY_EXTENSION).join("|");
20
21// An image path inside free text: absolute, ~/..., ./... or a bare relative name, ending in a known extension.
22const IMAGE_PATH_RE = new RegExp(`(?:^|[\\s'"=(\`:,\\[])((?:~|\\.{1,2})?/?[^\\s'"=()\`:;,|&<>\\[\\]]*\\.(?:${EXTENSIONS}))(?=$|[\\s'"),;:\`\\]])`, "gim");
23
24/** The MIME type for a path's extension, or "" when the Lightbox does not show that kind of file. */
25export function mimeForPath(path: string): string {
26 const ext = path.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
27 return MIME_BY_EXTENSION[ext] ?? "";
28}
29
30/** Image file paths mentioned in a piece of text, each once, in the order they appear. */
31export function imagePathsIn(text: string): string[] {
32 const out = new Set<string>();
33 const re = new RegExp(IMAGE_PATH_RE.source, "gim");
34 let m: RegExpExecArray | null;
35 while ((m = re.exec(String(text || "")))) if (m[1]) out.add(m[1]);
36 return [...out];
37}
38
39/** Image file paths in quotes inside a piece of text, such as a shell command's `"my diagram.png"`: spaces kept. */
40export function quotedImagePaths(text: string): string[] {
41 const re = new RegExp(`(["'])([^"'\\n]+\\.(?:${EXTENSIONS}))\\1`, "gi");
42 return [...String(text || "").matchAll(re)].map((m) => m[2]!);
43}
44
45/** A path made absolute: `~/x` against home, a relative one against the working directory. */
46export function absolutePath(path: string, cwd: string, home: string): string {
47 if (path.startsWith("~/")) return `${home.replace(/\/$/, "")}/${path.slice(2)}`;
48 if (path.startsWith("/")) return path;
49 const parts = `${cwd.replace(/\/$/, "")}/${path}`.split("/");
50 const out: string[] = [];
51 for (const part of parts) {
52 if (!part || part === ".") continue;
53 if (part === "..") out.pop();
54 else out.push(part);
55 }
56 return `/${out.join("/")}`;
57}
58
59export function basename(path: string): string {
60 return path.replace(/\/+$/, "").split("/").pop() || path;
61}
62
63/** Inline image bytes in a tool's result: MCP `{ type: "image", data, mimeType }` and Messages API `source` blocks, a few levels deep. */
64export function imageBlocks(value: unknown): { base64: string; mime: string }[] {
65 const out: { base64: string; mime: string }[] = [];
66 const visit = (v: unknown, depth: number) => {
67 if (depth > 4 || !v || typeof v !== "object") return;
68 if (Array.isArray(v)) { for (const item of v) visit(item, depth + 1); return; }
69 const block = v as Record<string, unknown>;
70 if (block.type === "image") {
71 if (typeof block.data === "string") out.push({ base64: block.data, mime: String(block.mimeType ?? "image/png") });
72 const source = block.source as Record<string, unknown> | undefined;
73 if (source?.type === "base64" && typeof source.data === "string") out.push({ base64: source.data, mime: String(source.media_type ?? "image/png") });
74 return;
75 }
76 if ("content" in block) visit(block.content, depth + 1);
77 };
78 visit(value, 0);
79 return out;
80}
81
82/** True when base64 bytes are a PNG file. */
83export function isPng(base64: string): boolean {
84 return base64.startsWith("iVBORw0KGgo");
85}
86
87/** How many bytes base64 text decodes to. */
88export function decodedSize(base64: string): number {
89 const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
90 return Math.floor((base64.length * 3) / 4) - padding;
91}
92
93const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
94
95/** The bytes of a short run of unpadded base64: enough to read a file header. */
96function headerBytes(base64: string): number[] {
97 const bytes: number[] = [];
98 let buffer = 0;
99 let bits = 0;
100 for (const ch of base64) {
101 const value = BASE64_ALPHABET.indexOf(ch);
102 if (value < 0) break;
103 buffer = (buffer << 6) | value;
104 bits += 6;
105 if (bits >= 8) {
106 bits -= 8;
107 bytes.push((buffer >> bits) & 0xff);
108 }
109 }
110 return bytes;
111}
112
113/** A PNG's pixel size from its header, or null when the bytes are not a PNG. */
114export function pngSize(base64: string): { width: number; height: number } | null {
115 if (!isPng(base64)) return null;
116 const head = headerBytes(base64.slice(0, 32));
117 const at = (i: number) => (((head[i] ?? 0) << 24) | ((head[i + 1] ?? 0) << 16) | ((head[i + 2] ?? 0) << 8) | (head[i + 3] ?? 0)) >>> 0;
118 const width = at(16);
119 const height = at(20);
120 return width > 0 && height > 0 ? { width, height } : null;
121}
122
123// A terminal cell is about twice as tall as it is wide; fonts and line spacing move it.
124export const CELL_ASPECT = 2.1;
125
126/**
127 * The largest box of cells within the limits that keeps the picture's proportions, for cells `aspect` times
128 * taller than wide. The terminal stretches a picture to fill its box, so a wrong aspect distorts it.
129 */
130export function fit(width: number, height: number, maxColumns: number, maxRows: number, aspect = CELL_ASPECT): { columns: number; rows: number } {
131 const clamp = (n: number, hi: number) => Math.max(1, Math.min(hi, Math.round(n)));
132 let columns = Math.min(maxColumns, 255);
133 let rows = (columns * height) / width / aspect;
134 if (rows > maxRows) {
135 rows = maxRows;
136 columns = (rows * aspect * width) / height;
137 }
138 return { columns: clamp(columns, Math.min(maxColumns, 255)), rows: clamp(rows, Math.min(maxRows, 255)) };
139}
140
141/** A tool name for people: `mcp__plugin_chrome-devtools-mcp_chrome-devtools__take_screenshot` → `chrome-devtools take_screenshot`. */
142export function toolLabel(tool: string): string {
143 const m = tool.match(/^mcp__(?:plugin_[^_]+_)?(.+?)__(.+)$/);
144 return m ? `${m[1]} ${m[2]}` : tool;
145}
146
147export function ago(ms: number): string {
148 const s = Math.max(0, Math.round(ms / 1000));
149 if (s < 60) return "just now";
150 if (s < 3600) return `${Math.round(s / 60)} min ago`;
151 return `${Math.round(s / 3600)} h ago`;
152}
153
154const FORMAT_LABELS: Record<string, string> = {
155 "image/png": "PNG",
156 "image/jpeg": "JPEG",
157 "image/gif": "GIF",
158 "image/webp": "WebP",
159 "image/heic": "HEIC",
160 "image/heif": "HEIF",
161 "image/avif": "AVIF",
162 "image/tiff": "TIFF",
163 "image/bmp": "BMP",
164 "image/svg+xml": "SVG"
165};
166
167/** A MIME type as people name the format: `image/heic` → HEIC. */
168export function formatLabel(mime: string): string {
169 return FORMAT_LABELS[mime] ?? mime.replace(/^image\//, "").toUpperCase();
170}
171
172/**
173 * The images right after a piece of text in the newest user message that holds it: where the conversation keeps a
174 * prompt the person queued while Claude worked, whose own row carries its text alone.
175 */
176export function imagesAfterText(messages: readonly { role: string; content: readonly unknown[] }[], text: string): { base64: string; mime: string }[] {
177 if (!text) return [];
178 for (let m = messages.length - 1; m >= 0; m -= 1) {
179 const message = messages[m]!;
180 if (message.role !== "user") continue;
181 const blocks = message.content as readonly { type?: string; text?: unknown }[];
182 // The last match: the same text queued twice in one message names the later delivery.
183 const at = blocks.findLastIndex((b) => b.type === "text" && typeof b.text === "string" && b.text.includes(text));
184 if (at < 0) continue;
185 let end = at + 1;
186 while (end < blocks.length && blocks[end]!.type === "image") end += 1;
187 return imageBlocks(blocks.slice(at + 1, end));
188 }
189 return [];
190}
191
192/** Files pasted into a prompt, which Claude Code notes as `[Image: source: /path]`; the path may hold spaces. */
193export function pastedImagePaths(text: string): string[] {
194 const out: string[] = [];
195 const re = /\[Image: source: ([^\]\n]+)\]/g;
196 let m: RegExpExecArray | null;
197 while ((m = re.exec(String(text || "")))) if (m[1]) out.push(m[1].trim());
198 return out;
199}
200types/index.d.ts 43 lines1/** One image on the reel: where it came from, and whether its pixels are ready to draw. */
2export type LightboxShot = {
3 id: string;
4 /** The file name, or the tool that produced an image with no file. */
5 title: string;
6 /** Who put it on the reel, in words: "read by Claude", "sent by Claude", "from peekaboo". */
7 origin: string;
8 /** The absolute path when the image is a file on this machine. */
9 path?: string;
10 /** A private copy of an image that came with no file behind it, so it can be drawn again after a reload. */
11 copy?: string;
12 /** The MIME type it arrived as. */
13 mime: string;
14 /** When it arrived, in milliseconds. */
15 at: number;
16 /** Shared by images that arrived together (the first one's id); the strip shows them side by side. */
17 batch?: string;
18 /** A line Claude attached when sending it. */
19 caption?: string;
20 /** Size of the prepared picture, in pixels. */
21 width?: number;
22 height?: number;
23 /** Size of the original image, in pixels. */
24 originalWidth?: number;
25 originalHeight?: number;
26 status: "pending" | "ready" | "failed";
27 /** Why it could not be shown. */
28 note?: string;
29};
30
31declare module "claude-code" {
32 interface PluginState {
33 lightbox: {
34 shots: LightboxShot[];
35 current: number;
36 /** The person hid the strip above the prompt; a new image shows it again. */
37 hidden: boolean;
38 /** The strip is folded to one line: the person sent a message with no image since these arrived. */
39 folded: boolean;
40 };
41 }
42}
43