SLOPSHOPPER

lightbox

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…

newpanebandguardcommandtoast
v0.3.2MITupdated 2026-10-09nuko-nova-dynamics/lightbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lightbox
│ ┃ lightbox ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Lightbox ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Images from this session appear here: ⏺ Update(src/auth.ts) │ ┃ whatever Claude reads, captures, generates ⎿ Added 2 lines, removed 1 line │ ┃ or sends you, and what you paste. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ /lightbox ~/Pictures/photo.heic opens any │ ┃ PNG, JPEG, GIF, WebP, HEIC/HEIF, AVIF, ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ TIFF, BMP and SVG file. │ ┃ ✻ Worked for 42s · done 4:20 PM │ │ › /lightbox │ ⎿ lightbox: No images yet. Pasted images, images Claude reads or s │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lightbox
Lightbox Images from this session appear here: whatever Claude reads, captures, generates or sends you, and what you paste. /lightbox ~/Pictures/photo.heic opens any PNG, JPEG, GIF, WebP, HEIC/HEIF, AVIF, TIFF, BMP and SVG file.
README

Lightbox

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:

  • images you paste into a prompt, named Image #1, Image #2 as the transcript names them
  • images Claude opens with the Read tool
  • screenshots and other images that tools return (Chrome DevTools, Peekaboo, simulator and computer-use tools)
  • image files a command or tool call just wrote: screencapture, xcrun simctl io booted screenshot, an image generator's output (but not Claude's own scratch files)
  • images Claude sends you on purpose with its show tool
  • any file you open with /lightbox path/to/image

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

Install

/plugin marketplace add nuko-nova-dynamics/marketplace
/plugin install lightbox@nuko-nova-tools

The strip

  • Images that arrive together, several pasted at once or sent at once, sit side by side, up to four. Each has a frame; the current one's is lit in its sender's color, cyan for yours and orange for Claude's.
  • Beside them: the current image's name, a dot for each image on the reel (the current one filled, batches spaced apart), who brought it in, when, its original size and its format.
  • Click ‹ 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.
  • When you send a message with no image, the strip folds to one line with its pictures a row tall. The next image opens it again.
  • /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.

How pictures are drawn

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.

A photo, and the same photo in quadrant cells at 64×23 cells

Real pixels inside herdr

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.

Proportions

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 } } }

Options

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

Developing

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

License

MIT

Source 4 files
hooks/register.tsx 1013 lines
1// 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};
1013
hooks/cells.ts 131 lines
1// 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}
131
hooks/images.ts 200 lines
1// 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}
200
types/index.d.ts 43 lines
1/** 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