Shows the screenshots and images Claude looks at in a pane beside the transcript

See what Claude sees. When Claude looks at an image, the picture shows up in a pane beside the transcript, so you watch the screenshot instead of reading a description of it. That covers:
.png, .jpg, .jpeg, .gif or .webp fileSubagent calls count too.
The pane shows the latest image as large as it fits with its aspect kept, a caption (tool, file or page, time), 3 of 7, and plain buttons:
p previous, n next, c closef full: the pane as wide and tall as Claude Code allows, picture redrawn to fit (f again restores)o opens the image in Preview, for real full screen outside the terminaly copies the image itself to the clipboard, w copies its pathIt keeps the last 20 images. Each one is copied (or converted with sips) to a PNG in a temp folder for the session, so the pane shows what Claude saw even if the file changes later. The folder is removed when the session ends.
Where the terminal draws real pixels (kitty graphics: Ghostty, kitty) the pane uses them. Where it cannot (inside herdr or tmux, say) the picture is drawn as truecolor half-block cells instead: sips scales it to the pane and writes a BMP, which the mod parses. Pictures with more colours than the cell grid paints at once are reduced to a 31 colour palette. The render option picks: auto (the default: pixels, falling back to blocks when the terminal says it drew the alt text), image or blocks.
On the desktop surface there is no picture element, so the pane shows the file path instead.
claude --plugin-dir ./mods/viewfinder
Then ask Claude to take a browser screenshot, or to read an image such as mods/viewfinder/tests/fixtures/tiny.png. Run /viewfinder at any time to open the pane, or /viewfinder full to open it as large as it goes; both work mid-turn.
Every new image is shown by itself. Claude Code only places a pane nobody asked for on a terminal at least 144 columns wide (110 once you have opened it yourself), so on a narrower terminal the image shows compactly in the band above the prompt instead, with keys v open the viewer, p / n step, x dismiss. The band picture goes when you send your next prompt. Closing the pane only closes it until the next image.
To stop it opening by itself, set the autoOpen option to false in /config. To force a drawing mode, set render. In settings:
{ "pluginConfigs": { "viewfinder@inline": { "options": { "autoOpen": false, "render": "blocks" } } } }
claude plugin validate --strict mods/viewfinder
claude plugin test mods/viewfinder
Tested on Claude Code 2.1.287, macOS, Ghostty.
o opens the real thing in Preview.auto the first picture shows as alt text for a moment until the terminal answers whether it drew pixels.sips, open, osascript). Without them, PNG image blocks are held in memory and drawn only where the terminal shows pixels.hooks/viewfinder.mjs 674 lines1// Viewfinder: shows the images Claude looks at (browser screenshots, image
2// files it reads, image blocks in any tool result) in a pane beside the
3// transcript.
4//
5// Function hooks for Claude Code 2.1.287. Load with --plugin-dir.
6
7import { cellsOf, fromBase64, parseBmp } from "./raster.mjs";
8
9const PANE_ID = "viewfinder";
10const PANE_TITLE = "Viewfinder";
11const MAX_ENTRIES = 20;
12const MAX_INLINE_BYTES = 2 * 1024 * 1024;
13// The band's picture stays compact: at most this many rows.
14const BAND_PICTURE_ROWS = 8;
15// Terminal cells are about twice as tall as they are wide.
16const CELL_ASPECT = 2;
17// Rows the pane spends on the caption and the two rows of controls.
18const CHROME_ROWS = 3;
19// Image box limits, and the Raster's.
20const IMAGE_MAX = { columns: 255, rows: 255 };
21const RASTER_MAX = { columns: 512, rows: 256 };
22const PROBE_DELAY_MS = 150;
23const PROBE_TRIES = 6;
24const BLOCK_CACHE = 4;
25const IMAGE_PATH = /\.(png|jpe?g|gif|webp)$/i;
26const IMAGE_PATH_IN_TEXT = /(\/[^\s"'`()<>[\]]+\.(?:png|jpe?g|gif|webp))/gi;
27const ARG_PATH_FIELDS = ["filePath", "filename", "path", "file_path", "savePath", "outputPath"];
28
29// Module state. `entries` is the history, oldest first; `index` the one shown.
30const state = {
31 entries: [],
32 index: -1,
33 isOpen: false,
34 isPlaced: false,
35 // Set when the person closed the pane: no auto-open until /viewfinder.
36 isDismissed: false,
37 // The latest image shown in the band above the prompt.
38 inBand: false,
39 seq: 0,
40 tmpDir: null,
41 queue: Promise.resolve(),
42 // Whether this terminal draws Image pixels: unknown until a blit says.
43 imageSupport: "unknown",
44 // Whether it reads { file } sources; false: send the bytes instead.
45 filesReadable: "unknown",
46 probeTimer: null,
47 probeTries: 0,
48 // Half-block frames by entry and size, and the ones being drawn.
49 blocks: new Map(),
50 drawing: new Set(),
51 failed: new Set(),
52 isFull: false,
53 viewport: { columns: 0, rows: 0 },
54 // The `render` option: auto, image or blocks.
55 render: "auto",
56};
57
58function baseName(path) {
59 const s = String(path || "");
60 const cut = s.lastIndexOf("/");
61 return cut < 0 ? s : s.slice(cut + 1);
62}
63
64function shortTool(tool) {
65 const s = String(tool || "");
66 const m = /^mcp__.+?__(.+)$/.exec(s);
67 return m ? m[1] : s;
68}
69
70function isPngPath(path) {
71 return /\.png$/i.test(path);
72}
73
74function decodeHead(base64, bytes) {
75 const head = String(base64).slice(0, Math.ceil(bytes / 3) * 4);
76 if (typeof Uint8Array.fromBase64 === "function") return Uint8Array.fromBase64(head);
77 const bin = atob(head);
78 const out = new Uint8Array(bin.length);
79 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
80 return out;
81}
82
83// Width and height from a PNG's IHDR chunk, or undefined.
84function pngSize(base64) {
85 try {
86 const b = decodeHead(base64, 24);
87 if (b.length < 24 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47) return undefined;
88 const word = (o) => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
89 const width = word(16), height = word(20);
90 return width > 0 && height > 0 ? { width, height } : undefined;
91 } catch {
92 return undefined;
93 }
94}
95
96// Image blocks anywhere in a tool result: MCP's `{ type, data, mimeType }`
97// and the Messages API's `{ type, source: { data, media_type } }`.
98function findImageBlocks(value, depth = 0, out = []) {
99 if (!value || typeof value !== "object" || depth > 5 || out.length >= 4) return out;
100 if (Array.isArray(value)) {
101 for (const v of value) findImageBlocks(v, depth + 1, out);
102 return out;
103 }
104 if (value.type === "image") {
105 if (typeof value.data === "string" && value.data) {
106 out.push({ data: value.data, mime: String(value.mimeType || value.media_type || "image/png") });
107 return out;
108 }
109 const src = value.source;
110 if (src && typeof src.data === "string" && src.data) {
111 out.push({ data: src.data, mime: String(src.media_type || src.mimeType || "image/png") });
112 return out;
113 }
114 }
115 for (const k of Object.keys(value)) {
116 if (k === "data" || k === "base64") continue;
117 findImageBlocks(value[k], depth + 1, out);
118 }
119 return out;
120}
121
122function textsOf(result) {
123 const texts = [];
124 if (typeof result?.text === "string") texts.push(result.text);
125 const content = Array.isArray(result?.result) ? result.result : result?.result?.content;
126 if (Array.isArray(content)) {
127 for (const b of content) if (b && b.type === "text" && typeof b.text === "string") texts.push(b.text);
128 } else if (typeof result?.result === "string") {
129 texts.push(result.result);
130 }
131 return texts;
132}
133
134// What one finished tool call showed Claude, or undefined. Synchronous and
135// cheap: the files and conversions are handled later, off the call's path.
136function captureOf(e, r) {
137 if (!r || r.deny !== undefined || r.isError) return undefined;
138 const tool = String(e.tool);
139
140 if (tool === "Read") {
141 const res = r.result;
142 const isImage = res?.type === "image" || IMAGE_PATH.test(String(e.file_path || ""));
143 if (!isImage) return undefined;
144 const dims = res?.file?.dimensions;
145 const size = dims?.originalWidth && dims?.originalHeight
146 ? { width: dims.originalWidth, height: dims.originalHeight }
147 : undefined;
148 const blocks = res?.type === "image" && typeof res.file?.base64 === "string"
149 ? [{ data: res.file.base64, mime: String(res.file.type || "image/png") }]
150 : [];
151 return { tool, label: baseName(e.file_path), paths: e.file_path ? [String(e.file_path)] : [], blocks, size };
152 }
153
154 const isScreenshot = /screenshot/i.test(tool);
155 const blocks = findImageBlocks(r.result);
156 if (!isScreenshot && !blocks.length) return undefined;
157
158 const paths = [];
159 const texts = textsOf(r);
160 if (isScreenshot) {
161 for (const f of ARG_PATH_FIELDS) {
162 const v = e[f];
163 if (typeof v === "string" && v.startsWith("/") && IMAGE_PATH.test(v)) paths.push(v);
164 }
165 for (const t of texts) for (const m of t.matchAll(IMAGE_PATH_IN_TEXT)) paths.push(m[1]);
166 }
167 if (!paths.length && !blocks.length) return undefined;
168
169 let label = "";
170 for (const t of texts) {
171 const m = /Page URL:\s*(\S+)/i.exec(t) || /\burl[:=]\s*(https?:\/\/\S+)/i.exec(t);
172 if (m) { label = m[1]; break; }
173 }
174 if (!label && typeof e.url === "string") label = e.url;
175 if (!label && paths.length) label = baseName(paths[0]);
176 if (!label) label = shortTool(tool);
177 return { tool, label, paths: [...new Set(paths)], blocks, size: undefined };
178}
179
180async function tmpDir($) {
181 if (state.tmpDir) return state.tmpDir;
182 let base = "/tmp";
183 try { base = ((await $.env.get("TMPDIR")) || "/tmp").replace(/\/+$/, ""); } catch { /* keep /tmp */ }
184 let id = "session";
185 try { id = String(await $.session.id()).replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || "session"; } catch { /* keep */ }
186 const dir = `${base}/viewfinder-${id}`;
187 const made = await $.process.run(["mkdir", "-p", dir]);
188 if (made.exitCode !== 0) throw new Error("mkdir failed");
189 state.tmpDir = dir;
190 return dir;
191}
192
193async function run($, argv, init) {
194 const r = await $.process.run(argv, init);
195 if (r.exitCode !== 0) throw new Error(`${argv[0]} exited ${r.exitCode}`);
196 return r;
197}
198
199// A copy of a file Claude looked at, as PNG in our temp folder, so the pane
200// shows what Claude saw even if the file changes later.
201async function pngCopyOf($, path) {
202 const dir = await tmpDir($);
203 const out = `${dir}/${++state.seq}.png`;
204 if (isPngPath(path)) await run($, ["cp", path, out]);
205 else await run($, ["sips", "-s", "format", "png", path, "--out", out]);
206 return out;
207}
208
209async function pngFileOfBlock($, block) {
210 const dir = await tmpDir($);
211 const n = ++state.seq;
212 const isPng = /png/i.test(block.mime);
213 const raw = isPng ? `${dir}/${n}.png` : `${dir}/${n}.src`;
214 await run($, ["base64", "-D", "-o", raw], { stdin: block.data });
215 if (isPng) return raw;
216 const out = `${dir}/${n}.png`;
217 await run($, ["sips", "-s", "format", "png", raw, "--out", out]);
218 await $.process.run(["rm", "-f", raw]).catch(() => undefined);
219 return out;
220}
221
222async function sizeOfFile($, path) {
223 try {
224 const r = await $.process.run(["sips", "-g", "pixelWidth", "-g", "pixelHeight", path]);
225 const w = /pixelWidth:\s*(\d+)/.exec(r.stdout), h = /pixelHeight:\s*(\d+)/.exec(r.stdout);
226 if (w && h) return { width: Number(w[1]), height: Number(h[1]) };
227 } catch { /* unknown size: fill the box */ }
228 return undefined;
229}
230
231// Turns a capture into an entry: a PNG file where possible, bytes otherwise.
232async function entryOf($, cap) {
233 let file, display, isTemp = false;
234 for (const p of cap.paths) {
235 let exists = false;
236 try { exists = await $.fs.exists(p); } catch { /* treat as missing */ }
237 if (!exists) continue;
238 display = p;
239 try { file = await pngCopyOf($, p); isTemp = true; }
240 catch { if (isPngPath(p)) file = p; }
241 if (file) break;
242 }
243 let png;
244 if (!file) {
245 for (const b of cap.blocks) {
246 try { file = await pngFileOfBlock($, b); isTemp = true; break; }
247 catch {
248 if (/png/i.test(b.mime) && b.data.length * 0.75 <= MAX_INLINE_BYTES) { png = b.data; break; }
249 }
250 }
251 }
252 if (!file && !png) return undefined;
253
254 let size = cap.size;
255 if (!size && png) size = pngSize(png);
256 if (!size) {
257 const pngBlock = cap.blocks.find((b) => /png/i.test(b.mime));
258 if (pngBlock) size = pngSize(pngBlock.data);
259 }
260 if (!size && file) size = await sizeOfFile($, file);
261
262 let at = 0;
263 try { at = await $.clock.now(); } catch { /* no time */ }
264 return {
265 id: state.seq,
266 tool: shortTool(cap.tool),
267 label: cap.label,
268 path: display || file || "",
269 file,
270 png,
271 isTemp,
272 size,
273 at,
274 };
275}
276
277function addEntry($, entry) {
278 state.entries.push(entry);
279 while (state.entries.length > MAX_ENTRIES) {
280 const old = state.entries.shift();
281 if (old.isTemp && old.file) $.process.run(["rm", "-f", old.file]).catch(() => undefined);
282 }
283 state.index = state.entries.length - 1;
284}
285
286// Every new image is shown: in the pane, or, where an unasked pane is not
287// placed (a narrow terminal), in the band above the prompt.
288async function show($, entry, autoOpen) {
289 if (!autoOpen) {
290 if (state.isOpen) $.ui.invalidate("ui.render");
291 return;
292 }
293 state.isDismissed = false;
294 if (!state.isOpen || !state.isPlaced) {
295 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE });
296 state.isOpen = true;
297 state.isPlaced = opened?.isPlaced === true;
298 }
299 state.inBand = !state.isPlaced;
300 $.ui.invalidate("ui.render");
301}
302
303async function processCapture($, cap, autoOpen) {
304 const entry = await entryOf($, cap);
305 if (!entry) return;
306 addEntry($, entry);
307 await show($, entry, autoOpen);
308}
309
310function timeOf(ms) {
311 if (!ms) return "";
312 const d = new Date(ms);
313 const two = (n) => String(n).padStart(2, "0");
314 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`;
315}
316
317// The picture's box in cells: as large as the room allows, aspect kept.
318// A cell is about twice as tall as wide, so a half-block pixel is square.
319function fitCells(size, cols, rows, max) {
320 const maxCols = Math.max(1, Math.min(max.columns, cols));
321 const maxRows = Math.max(1, Math.min(max.rows, rows));
322 if (!size) return { columns: maxCols, rows: maxRows };
323 const ratio = (size.width * CELL_ASPECT) / size.height;
324 let h = maxRows;
325 let w = Math.round(h * ratio);
326 if (w > maxCols) {
327 w = maxCols;
328 h = Math.round(w / ratio);
329 }
330 return { columns: Math.max(1, Math.min(maxCols, w)), rows: Math.max(1, Math.min(maxRows, h)) };
331}
332
333function current() {
334 const n = state.entries.length;
335 return n ? state.entries[Math.max(0, Math.min(state.index, n - 1))] : undefined;
336}
337
338function go($, to) {
339 const n = state.entries.length;
340 if (!n) return;
341 state.index = Math.max(0, Math.min(n - 1, to));
342 $.ui.invalidate("ui.render");
343}
344
345function closePane($) {
346 state.isOpen = false;
347 state.isPlaced = false;
348 state.isDismissed = true;
349 state.isFull = false;
350 $.ui.close({ id: PANE_ID });
351}
352
353// Opens the pane as the person asked: the share Claude Code gives it, or
354// full, as wide and tall as the layout spares.
355async function openPane($, full, columns) {
356 state.isDismissed = false;
357 state.inBand = false;
358 state.isFull = full;
359 const size = full
360 ? { columns: Math.max(40, (columns || state.viewport.columns || 300) - 1), rows: Math.max(12, state.viewport.rows || 100) }
361 : {};
362 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true, ...size });
363 state.isOpen = true;
364 state.isPlaced = opened?.isPlaced === true;
365 $.ui.invalidate("ui.render");
366 return state.isPlaced;
367}
368
369function imageSourceOf(entry) {
370 if (entry.file && state.filesReadable !== false) return { file: entry.file, format: "png" };
371 if (entry.png) return { png: entry.png };
372 return undefined;
373}
374
375function wantsImage() {
376 if (state.render === "blocks") return false;
377 if (state.render === "image") return true;
378 return state.imageSupport !== "no";
379}
380
381// The terminal says through a blit whether the Image drew pixels or its
382// alt: no capability flag reaches a plugin, so ask once it is mounted.
383function scheduleProbe($, requestId) {
384 if (state.probeTimer || state.probeTries >= PROBE_TRIES) return;
385 state.probeTimer = $.clock.after(PROBE_DELAY_MS, () => {
386 state.probeTimer = null;
387 probe($, requestId).catch(() => undefined);
388 });
389}
390
391async function probe($, requestId) {
392 const entry = current();
393 const source = entry && imageSourceOf(entry);
394 if (!source || !(state.isOpen || state.inBand)) return;
395 state.probeTries++;
396 const r = await $.ui.blit({ requestId, key: "shot", source });
397 const why = r?.deny;
398 if (!why) {
399 state.imageSupport = "yes";
400 if (source.file) state.filesReadable = true;
401 state.probeTries = 0;
402 return;
403 }
404 if (/cannot read files/i.test(why)) {
405 state.filesReadable = false;
406 state.probeTries = 0;
407 await ensureBytes($, entry);
408 $.ui.invalidate("ui.render");
409 return;
410 }
411 if (/draws its alt/i.test(why)) {
412 state.imageSupport = "no";
413 $.ui.invalidate("ui.render");
414 return;
415 }
416 // Not mounted yet, or the terminal has not answered: ask again.
417 scheduleProbe($, requestId);
418}
419
420// PNG bytes for a terminal that cannot read our files.
421async function ensureBytes($, entry) {
422 if (!entry || entry.png || !entry.file) return;
423 try {
424 const st = await $.fs.stat(entry.file);
425 if (st.size > MAX_INLINE_BYTES) return;
426 const read = await $.fs.read(entry.file, { as: "bytes" });
427 entry.png = read.base64;
428 } catch { /* stays a file source */ }
429}
430
431function blockKey(entry, box) {
432 return `${entry.id}:${box.columns}x${box.rows}`;
433}
434
435// Draws one half-block frame off the render path: sips scales the PNG to
436// the box's pixels as a BMP, which is parsed here.
437function scheduleBlocks($, entry, box) {
438 const key = blockKey(entry, box);
439 if (state.drawing.has(key) || state.failed.has(key) || !entry.file) return;
440 state.drawing.add(key);
441 $.clock.after(0, () => {
442 state.queue = state.queue
443 .then(() => drawBlocks($, entry, box, key))
444 .catch(() => { state.failed.add(key); })
445 .then(() => { state.drawing.delete(key); $.ui.invalidate("ui.render"); });
446 });
447}
448
449async function drawBlocks($, entry, box, key) {
450 const dir = await tmpDir($);
451 const bmp = `${dir}/frame-${entry.id}.bmp`;
452 try {
453 await run($, ["sips", "-s", "format", "bmp", "-z", String(box.rows * 2), String(box.columns), entry.file, "--out", bmp]);
454 const read = await $.fs.read(bmp, { as: "bytes" });
455 const img = parseBmp(fromBase64(read.base64));
456 state.blocks.set(key, cellsOf(img, box.columns, box.rows));
457 while (state.blocks.size > BLOCK_CACHE) state.blocks.delete(state.blocks.keys().next().value);
458 } finally {
459 $.process.run(["rm", "-f", bmp]).catch(() => undefined);
460 }
461}
462
463async function openInPreview($, entry) {
464 const r = await $.process.run(["open", "-a", "Preview", entry.file]).catch(() => undefined);
465 if (r?.exitCode !== 0) $.ui.toast("Viewfinder: could not open Preview");
466}
467
468async function copyImage($, entry) {
469 const r = await $.process.run([
470 "osascript",
471 "-e", "on run argv",
472 "-e", "set the clipboard to (read (POSIX file (item 1 of argv)) as «class PNGf»)",
473 "-e", "end run",
474 entry.file,
475 ]).catch(() => undefined);
476 $.ui.toast(r?.exitCode === 0 ? "Viewfinder: image copied" : "Viewfinder: could not copy the image");
477}
478
479async function copyPath($, entry, surface) {
480 const r = await $.ui.copy({ text: entry.path, surface }).catch(() => undefined);
481 $.ui.toast(r?.isCopied ? "Viewfinder: path copied" : "Viewfinder: could not copy the path");
482}
483
484function pictureOf($, e, entry, cols, rows) {
485 const { Text } = $.ui.resolve(e);
486 if (e.surface !== "terminal") {
487 return Text({ wrap: "truncate-start", children: entry.path || "(image held in memory)" });
488 }
489 const { Image, Raster } = $.ui.resolve(e);
490 const alt = `Image: ${entry.label}`;
491 if (wantsImage()) {
492 const source = imageSourceOf(entry);
493 if (source) {
494 const box = fitCells(entry.size, cols, rows, IMAGE_MAX);
495 if (state.imageSupport === "unknown" || (source.file && state.filesReadable === "unknown")) scheduleProbe($, e.requestId);
496 return Image({ key: "shot", source, columns: box.columns, rows: box.rows, alt });
497 }
498 }
499 const box = fitCells(entry.size, cols, rows, RASTER_MAX);
500 const cells = state.blocks.get(blockKey(entry, box));
501 if (cells) return Raster({ key: "blocks", columns: box.columns, rows: box.rows, cells });
502 const key = blockKey(entry, box);
503 if (state.failed.has(key) || !entry.file) return Text({ dimColor: true, children: alt });
504 scheduleBlocks($, entry, box);
505 return Text({ dimColor: true, children: `Drawing ${entry.label}...` });
506}
507
508function paneView($, e) {
509 const { Box, Text, Button } = $.ui.resolve(e);
510 const cols = Math.max(10, e.props?.bodyColumns || e.viewport?.columns || 60);
511 const bodyRows = e.props?.scroll?.bodyRows || e.viewport?.rows || 20;
512 if (e.viewport?.columns) state.viewport = { columns: e.viewport.columns, rows: e.viewport.rows };
513 const total = state.entries.length;
514 const close = Button({ key: "close", label: "Close", hotkey: "c", plain: true, onPress: () => closePane($) });
515
516 if (!total) {
517 return Box({ flexDirection: "column", children: [
518 Text({ dimColor: true, children: "No images yet. Screenshots and images Claude reads show up here." }),
519 Box({ flexDirection: "row", gap: 2, children: [close] }),
520 ] });
521 }
522
523 const k = Math.max(0, Math.min(state.index, total - 1));
524 const entry = state.entries[k];
525 const caption = [entry.tool, entry.label, timeOf(entry.at)].filter(Boolean).join(" · ");
526 const actions = [
527 Button({ key: "full", label: state.isFull ? "Restore" : "Full", hotkey: "f", plain: true,
528 onPress: () => openPane($, !state.isFull) }),
529 ];
530 if (entry.file) {
531 actions.push(
532 Button({ key: "open", label: "Preview", hotkey: "o", plain: true, onPress: () => openInPreview($, entry) }),
533 Button({ key: "copy", label: "Copy image", hotkey: "y", plain: true, onPress: () => copyImage($, entry) }),
534 );
535 }
536 if (entry.path) {
537 actions.push(Button({ key: "copy-path", label: "Copy path", hotkey: "w", plain: true,
538 onPress: (press) => copyPath($, entry, press.surface) }));
539 }
540
541 return Box({ flexDirection: "column", children: [
542 pictureOf($, e, entry, cols, Math.max(1, bodyRows - CHROME_ROWS)),
543 Text({ key: "caption", dimColor: true, wrap: "truncate-end", children: caption }),
544 Box({ flexDirection: "row", gap: 2, children: [
545 Text({ key: "count", bold: true, children: `${k + 1} of ${total}` }),
546 Button({ key: "prev", label: "Prev", hotkey: "p", plain: true, onPress: () => go($, k - 1) }),
547 Button({ key: "next", label: "Next", hotkey: "n", plain: true, onPress: () => go($, k + 1) }),
548 close,
549 ] }),
550 Box({ flexDirection: "row", gap: 2, flexWrap: "wrap", children: actions }),
551 ] });
552}
553
554function hideBand($) {
555 state.inBand = false;
556 $.ui.invalidate("ui.render");
557}
558
559// The band: the latest image, compact, under whatever the band already holds.
560async function bandView($, e, next) {
561 const below = await next(e);
562 const entry = current();
563 if (!entry) return below;
564 const { Box, Text, Button } = $.ui.resolve(e);
565 const cols = Math.max(10, e.props?.bodyColumns || 80);
566 const rows = Math.max(1, Math.min(BAND_PICTURE_ROWS, (e.props?.maxRows || 12) - 3));
567 const total = state.entries.length;
568 const k = Math.max(0, Math.min(state.index, total - 1));
569 const caption = [entry.tool, entry.label, timeOf(entry.at), total > 1 ? `${k + 1} of ${total}` : ""]
570 .filter(Boolean).join(" · ");
571 const keys = [Button({ key: "band-view", label: "View", hotkey: "v", plain: true, onPress: () => openPane($, false) })];
572 if (total > 1) {
573 keys.push(
574 Button({ key: "band-prev", label: "Prev", hotkey: "p", plain: true, onPress: () => go($, k - 1) }),
575 Button({ key: "band-next", label: "Next", hotkey: "n", plain: true, onPress: () => go($, k + 1) }),
576 );
577 }
578 keys.push(Button({ key: "band-dismiss", label: "Dismiss", hotkey: "x", plain: true, onPress: () => hideBand($) }));
579 return Box({ flexDirection: "column", children: [
580 below,
581 Box({ flexDirection: "column", paddingX: 1, children: [
582 pictureOf($, e, entry, cols - 2, rows),
583 Text({ key: "band-caption", dimColor: true, wrap: "truncate-end", children: caption }),
584 Box({ flexDirection: "row", gap: 2, children: keys }),
585 ] }),
586 ] });
587}
588
589export function register(on, options) {
590 const autoOpen = options?.autoOpen !== false;
591 state.render = ["auto", "image", "blocks"].includes(options?.render) ? options.render : "auto";
592
593 on("session.start", async ($, e, next) => {
594 const r = await next(e);
595 try {
596 await $.command.register({
597 name: "viewfinder",
598 description: "Show the images Claude has looked at in a pane (full: as large as it goes)",
599 argumentHint: "[full]",
600 immediate: true,
601 });
602 } catch { /* the pane still auto-opens */ }
603 return r;
604 });
605
606 on("command.run", { command: "viewfinder" }, async ($, e) => {
607 if (state.entries.length) state.index = state.entries.length - 1;
608 const full = /^\s*full\b/i.test(e.args || "");
609 const placed = await openPane($, full, e.presentation?.columns);
610 const n = state.entries.length;
611 if (!placed) return { text: "Viewfinder: this surface has no room for a pane right now." };
612 return { text: n ? `Viewfinder: ${n} image${n === 1 ? "" : "s"}.` : "Viewfinder: no images yet." };
613 });
614
615 // Observe-only: the call runs and its result goes back untouched. The
616 // files and conversions run on a timer, off the call's path.
617 on("tool.call", async ($, e, next) => {
618 const r = await next(e);
619 try {
620 const cap = captureOf(e, r);
621 if (cap) {
622 $.clock.after(0, () => {
623 state.queue = state.queue
624 .then(() => processCapture($, cap, autoOpen))
625 .catch(() => undefined);
626 });
627 }
628 } catch { /* never let capture touch the call */ }
629 return r;
630 });
631
632 on("ui.render", { component: "Pane", requestId: PANE_ID }, ($, e) => {
633 state.isOpen = true;
634 state.isPlaced = true;
635 state.inBand = false;
636 return paneView($, e);
637 });
638
639 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
640 if (!state.inBand || state.isPlaced || !state.entries.length) return next(e);
641 return bandView($, e, next);
642 });
643
644 // The band's picture goes when the person moves on.
645 on("prompt.submit", ($, e, next) => {
646 if (state.inBand) hideBand($);
647 return next(e);
648 });
649
650 // The mod's own close resets state in closePane, so only the person's close
651 // and an unload land here (matching the mod's own would re-enter the hook)
652 on("ui.close", { id: PANE_ID, origin: { kind: ["person", "unload"] } }, ($, e, next) => {
653 state.isOpen = false;
654 state.isPlaced = false;
655 state.isFull = false;
656 if (e.origin?.kind === "person") state.isDismissed = true;
657 return next(e);
658 });
659
660 on("session.end", async ($, e, next) => {
661 const dir = state.tmpDir;
662 state.tmpDir = null;
663 state.entries = [];
664 state.index = -1;
665 state.blocks.clear();
666 state.failed.clear();
667 $.ui.invalidate("ui.render");
668 if (dir && /\/viewfinder-[A-Za-z0-9_-]+$/.test(dir)) {
669 await $.process.run(["rm", "-rf", dir]).catch(() => undefined);
670 }
671 return next(e);
672 });
673}
674hooks/raster.mjs 185 lines1// Half-block pictures: decode a BMP and pack it as Raster cells, two pixels
2// a cell ('▀', foreground the top pixel, background the bottom one).
3
4const UPPER_HALF = 0x2580;
5const LOWER_HALF = 0x2584;
6const SPACE = 0x20;
7const DEFAULT_COLOR = 0x01000000;
8// The Raster paints 1024 colour pairs at once; 31 colours plus the
9// terminal's default make exactly that many.
10const MAX_PAIRS = 1024;
11const PALETTE_SIZE = 31;
12
13export function fromBase64(base64) {
14 if (typeof Uint8Array.fromBase64 === "function") return Uint8Array.fromBase64(base64);
15 const bin = atob(base64);
16 const out = new Uint8Array(bin.length);
17 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
18 return out;
19}
20
21export function toBase64(bytes) {
22 if (typeof bytes.toBase64 === "function") return bytes.toBase64();
23 let bin = "";
24 for (let i = 0; i < bytes.length; i += 0x8000) {
25 bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
26 }
27 return btoa(bin);
28}
29
30function maskShift(mask) {
31 if (!mask) return { shift: 0, max: 0 };
32 let shift = 0;
33 while (((mask >>> shift) & 1) === 0) shift++;
34 return { shift, max: mask >>> shift };
35}
36
37// An uncompressed BMP (24 or 32 bits, BI_RGB or bitfields, either row
38// order) as RGBA pixels, top row first.
39export function parseBmp(bytes) {
40 if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) throw new Error("not a BMP");
41 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
42 const offset = view.getUint32(10, true);
43 const headerSize = view.getUint32(14, true);
44 const width = view.getInt32(18, true);
45 const rawHeight = view.getInt32(22, true);
46 const bpp = view.getUint16(28, true);
47 const compression = view.getUint32(30, true);
48 const height = Math.abs(rawHeight);
49 const isTopDown = rawHeight < 0;
50 if (width <= 0 || height <= 0 || (bpp !== 24 && bpp !== 32)) throw new Error(`unsupported BMP (${bpp} bits)`);
51 if (compression !== 0 && compression !== 3 && compression !== 6) throw new Error("compressed BMP");
52
53 let masks = bpp === 32 ? [0xff0000, 0xff00, 0xff, 0] : [0xff0000, 0xff00, 0xff, 0];
54 if (compression === 3 || compression === 6) {
55 // Masks follow a 40-byte header, or sit inside a V4/V5 one.
56 const at = 54;
57 masks = [view.getUint32(at, true), view.getUint32(at + 4, true), view.getUint32(at + 8, true),
58 headerSize >= 56 || compression === 6 ? view.getUint32(at + 12, true) : 0];
59 }
60 const [r, g, b, a] = masks.map(maskShift);
61 const stride = Math.ceil((bpp * width) / 32) * 4;
62 const rgba = new Uint8Array(width * height * 4);
63 for (let y = 0; y < height; y++) {
64 const row = offset + (isTopDown ? y : height - 1 - y) * stride;
65 for (let x = 0; x < width; x++) {
66 const o = (y * width + x) * 4;
67 if (bpp === 24) {
68 const p = row + x * 3;
69 rgba[o] = bytes[p + 2];
70 rgba[o + 1] = bytes[p + 1];
71 rgba[o + 2] = bytes[p];
72 rgba[o + 3] = 255;
73 } else {
74 const v = view.getUint32(row + x * 4, true);
75 const ch = (m) => (m.max ? Math.round((((v >>> m.shift) & m.max) * 255) / m.max) : 255);
76 rgba[o] = ch(r);
77 rgba[o + 1] = ch(g);
78 rgba[o + 2] = ch(b);
79 rgba[o + 3] = a.max ? ch(a) : 255;
80 }
81 }
82 }
83 return { width, height, rgba };
84}
85
86// Nearest-neighbour resample, for a picture that came back another size.
87function resample(img, width, height) {
88 if (img.width === width && img.height === height) return img;
89 const rgba = new Uint8Array(width * height * 4);
90 for (let y = 0; y < height; y++) {
91 const sy = Math.min(img.height - 1, Math.floor(((y + 0.5) * img.height) / height));
92 for (let x = 0; x < width; x++) {
93 const sx = Math.min(img.width - 1, Math.floor(((x + 0.5) * img.width) / width));
94 const s = (sy * img.width + sx) * 4, o = (y * width + x) * 4;
95 rgba[o] = img.rgba[s]; rgba[o + 1] = img.rgba[s + 1]; rgba[o + 2] = img.rgba[s + 2]; rgba[o + 3] = img.rgba[s + 3];
96 }
97 }
98 return { width, height, rgba };
99}
100
101// A median-cut palette over the given colours (0xRRGGBB).
102function medianCut(colors, size) {
103 let boxes = [colors];
104 while (boxes.length < size) {
105 let best = -1, bestRange = 0, bestCh = 0;
106 boxes.forEach((box, i) => {
107 if (box.length < 2) return;
108 for (let ch = 0; ch < 3; ch++) {
109 let lo = 255, hi = 0;
110 for (const c of box) { const v = (c >> (16 - ch * 8)) & 255; if (v < lo) lo = v; if (v > hi) hi = v; }
111 if (hi - lo > bestRange) { bestRange = hi - lo; best = i; bestCh = ch; }
112 }
113 });
114 if (best < 0) break;
115 const shift = 16 - bestCh * 8;
116 const box = boxes[best].slice().sort((p, q) => ((p >> shift) & 255) - ((q >> shift) & 255));
117 const mid = box.length >> 1;
118 boxes.splice(best, 1, box.slice(0, mid), box.slice(mid));
119 }
120 return boxes.filter((b) => b.length).map((box) => {
121 let r = 0, g = 0, b = 0;
122 for (const c of box) { r += (c >> 16) & 255; g += (c >> 8) & 255; b += c & 255; }
123 const n = box.length;
124 return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n);
125 });
126}
127
128function nearest(palette, c) {
129 const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
130 let best = palette[0], bestD = Infinity;
131 for (const p of palette) {
132 const dr = ((p >> 16) & 255) - r, dg = ((p >> 8) & 255) - g, db = (p & 255) - b;
133 const d = dr * dr * 3 + dg * dg * 4 + db * db * 2;
134 if (d < bestD) { bestD = d; best = p; }
135 }
136 return best;
137}
138
139// RGBA pixels as `columns x rows` half-block cells, base64 for a Raster.
140// Transparent pixels show the terminal's own background.
141export function cellsOf(img, columns, rows) {
142 const px = resample(img, columns, rows * 2);
143 const at = (x, y) => {
144 const o = (y * columns + x) * 4;
145 return px.rgba[o + 3] < 128 ? -1 : (px.rgba[o] << 16) | (px.rgba[o + 1] << 8) | px.rgba[o + 2];
146 };
147 const tops = new Int32Array(columns * rows), bottoms = new Int32Array(columns * rows);
148 const pairs = new Set();
149 for (let y = 0; y < rows; y++) {
150 for (let x = 0; x < columns; x++) {
151 const i = y * columns + x;
152 tops[i] = at(x, y * 2);
153 bottoms[i] = at(x, y * 2 + 1);
154 if (pairs.size <= MAX_PAIRS) pairs.add(tops[i] * 16777217 + bottoms[i]);
155 }
156 }
157 // Too many pairs for the Raster's palette: map to a palette of our own,
158 // so the picture degrades evenly instead of by draw order.
159 if (pairs.size > MAX_PAIRS) {
160 const seen = new Set();
161 for (let i = 0; i < tops.length; i++) {
162 if (tops[i] >= 0) seen.add(tops[i]);
163 if (bottoms[i] >= 0) seen.add(bottoms[i]);
164 }
165 const palette = medianCut([...seen], PALETTE_SIZE);
166 const memo = new Map();
167 const map = (c) => {
168 if (c < 0) return c;
169 let m = memo.get(c);
170 if (m === undefined) { m = nearest(palette, c); memo.set(c, m); }
171 return m;
172 };
173 for (let i = 0; i < tops.length; i++) { tops[i] = map(tops[i]); bottoms[i] = map(bottoms[i]); }
174 }
175
176 const words = new Uint32Array(columns * rows * 3);
177 for (let i = 0; i < tops.length; i++) {
178 const t = tops[i], b = bottoms[i], o = i * 3;
179 if (t < 0 && b < 0) { words[o] = SPACE; words[o + 1] = DEFAULT_COLOR; words[o + 2] = DEFAULT_COLOR; }
180 else if (t < 0) { words[o] = LOWER_HALF; words[o + 1] = b; words[o + 2] = DEFAULT_COLOR; }
181 else { words[o] = UPPER_HALF; words[o + 1] = t; words[o + 2] = b < 0 ? DEFAULT_COLOR : b; }
182 }
183 return toBase64(new Uint8Array(words.buffer));
184}
185