SLOPSHOPPER

viewfinder

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

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-02adamentwistle/claude-code-mods/plugins/viewfinder
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · viewfinder
│ ┃ Viewfinder ✕ › fix the failing auth test and add an audit log call │ ┃ No images yet. Screenshots and images Claude │ ┃ reads show up here. ⏺ Read(src/auth.ts) │ ┃ c: Close ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /viewfinder │ ⎿ viewfinder: Viewfinder: no images yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Viewfinder
No images yet. Screenshots and images Claude reads show up here. c: Close
README

Viewfinder

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:

  • browser screenshots from the Playwright and Chrome DevTools MCP tools (any tool with "screenshot" in its name), whether the tool returns the image or saves it to a file
  • a Read of a .png, .jpg, .jpeg, .gif or .webp file
  • any other tool result that carries an image block

Subagent 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 close
  • f 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 terminal
  • y copies the image itself to the clipboard, w copies its path

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

Load it

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

Check it

claude plugin validate --strict mods/viewfinder
claude plugin test mods/viewfinder

Tested on Claude Code 2.1.287, macOS, Ghostty.

Limits

  • Half-block pictures have two pixels per cell, so detail is coarse; o opens the real thing in Preview.
  • In auto the first picture shows as alt text for a moment until the terminal answers whether it drew pixels.
  • Converting JPEG, GIF and WebP, drawing half-blocks, opening Preview and copying the image all use macOS tools (sips, open, osascript). Without them, PNG image blocks are held in memory and drawn only where the terminal shows pixels.
  • The aspect ratio assumes terminal cells about twice as tall as they are wide.
  • Full size is a request: a pane width you dragged yourself wins.
  • A screenshot tool that saves to a relative path and returns no image block is missed: only absolute paths are followed.
  • History lives in memory and starts over when the mod reloads.
Source 2 files
hooks/viewfinder.mjs 674 lines
1// 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}
674
hooks/raster.mjs 185 lines
1// 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