SLOPSHOPPER

show-and-tell

📸 Shows you the pictures: a thumbnail the moment you paste one, and /gallery of every image you and Claude passed around this session.

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · show-and-tell
│ ┃ 📸 Show & Tell ✕ › fix the failing auth test and add an audit log call │ ┃ 📸 Show & Tell · 0 images this session a: │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing yet. Paste an image with ctrl+v, or ⎿ Read 6 lines │ ┃ let Claude read or make one. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Kept 7 days · tab walks the list · esc ⏺ Bash(bun test) │ ┃ closes ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /gallery │ ⎿ show-and-tell: 📸 Gallery open. Esc closes. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 📸 gallery

Draws

Pane · 📸 Show & Tell
📸 Show & Tell · 0 images this session a: All y: You c: Nothing yet. Paste an image with ctrl+v, or let Claude read or make one. Kept 7 days · tab walks the list · esc closes
Prompt hint
⟨Claude Code's own drawing⟩ 📸 gallery
README

📸 Show & Tell

Shows a thumbnail as soon as you paste a screenshot, and keeps a gallery of every image in the session.

A pasted screenshot of a broken checkout shows as a thumbnail before sending; Claude fixes it and snapshots the page, and /gallery shows both

/plugin install show-and-tell@claude-desk-neighbours

How it works

Hit ctrl+v with an image on your clipboard and a thumbnail appears above the prompt within about a quarter of a second, before you've sent anything. Show & Tell watches your draft for Claude Code's [Image #1] and copies the image Claude Code saved for it. If that file isn't where it expects, it reads the clipboard itself (osascript on macOS, wl-paste or xclip on Linux, PowerShell on Windows). If that doesn't work either, it grabs the image when you send the prompt.

Claude's pictures get picked up the same way. That covers images Claude opens with Read, screenshots returned by browser or MCP tools, pictures Claude writes or edits, and images a shell command creates in the working directory or one level down (playwright screenshot, a chart script).

The band above the prompt shows the newest thumbnails, with Gallery and Dismiss buttons. A pasted thumbnail goes away when you send it. Everything else disappears after 20 seconds, which you can change in /config.

The gallery

Click 📸 4 in the status row, or run /gallery, to see every image from this session, newest first, with a big preview of whichever one you select. a, y and c filter to all images, yours, or Claude's. o opens the image in your viewer, i puts @path in your prompt so Claude can look at it again, p copies the path, and f shows it in its folder. Tab moves through the list. This is after Claude fixed the checkout and took a snapshot:

The gallery: the snapshot Claude made previewed beside its actions, with the pasted screenshot below it in the list

The gallery asks for enough room to show a picture. If it doesn't get it (say, an inline pane in a small terminal), the picture shrinks to fit and the footer is hidden so the header, actions and list stay visible.

Which terminals show pictures

Pictures only render in terminals that support the kitty graphics protocol, which means Ghostty and kitty. In other terminals, in tmux and in the desktop app, the band and gallery show the same thing as text (📎 Pasted image #1 · 200×120), and all the buttons still work.

Thumbnails have to be PNGs, so other formats get converted once: with sips on macOS and ImageMagick (magick or convert) elsewhere. On macOS, qlmanage handles SVG and PDF. If there's no converter, the image is still listed, just without a picture.

In the background

There are no model calls. Pasted images, and images that only exist inside a tool's result, are copied to ~/.claude/show-and-tell/<session>/ next to a manifest.json that restores the gallery after /resume. Files Claude read or created stay where they are. Copies older than seven days are deleted when a session starts. The cleanup uses find, so on Windows they stick around until you delete them yourself.

While a session is open, the draft is checked four times a second. That's just a read of the prompt box, and nothing runs unless a new [Image #N] shows up. Where Claude Code saves pastes (/tmp/claude-<uid>/…/images/) isn't part of the plugin API, so a future version of Claude Code might move it. If that happens, Show & Tell falls back to reading the clipboard.

Source 2 files
hooks/register.tsx 733 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Shot, ShotSource } from '../types'
5
6const SHOTS = { plugin: 'show-and-tell', key: 'shots' } as const
7const FRESH = { plugin: 'show-and-tell', key: 'fresh' } as const
8const PICKED = { plugin: 'show-and-tell', key: 'picked' } as const
9const FILTER = { plugin: 'show-and-tell', key: 'filter' } as const
10
11const shots = atom(SHOTS, [])
12const fresh = atom(FRESH, [])
13const picked = atom(PICKED, null)
14const filter = atom(FILTER, 'all')
15
16const PANE = 'show-and-tell'
17const TITLE = '📸 Show & Tell'
18
19const POLL_MS = 250
20const KEEP_DAYS = 7
21const MAX_SHOTS = 200
22const MAX_READ = 4 * 1024 * 1024
23const THUMB_COLUMNS = 16
24const PREVIEW_COLUMNS = 72
25const GALLERY_ROWS = 24
26const GALLERY_COLUMNS = 90
27// From this width the preview sits beside the details and the list, not above them.
28const SIDE_BY_SIDE = 70
29const SCAN_DIRS = 40
30
31const PICTURE = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|svg|pdf)$/i
32const VECTOR = /\.(svg|pdf)$/i
33const IMAGE_TOKEN = /\[Image #(\d+)\]/g
34const ICON: Record<ShotSource, string> = { you: '📎', 'claude-read': '👀', 'claude-made': '🎨' }
35
36type Block = { type: string; [field: string]: unknown }
37type Platform = 'mac' | 'linux' | 'windows'
38type Look = { thumb: string | null; width: number | null; height: number | null }
39
40/** The image numbers a draft holds, as Claude Code writes a paste into it: `[Image #2]`. */
41export function imageNumbers(text: string): number[] {
42  return [...text.matchAll(IMAGE_TOKEN)].map(match => Number(match[1]))
43}
44
45/** What kind of picture base64 bytes are, by their first bytes. */
46export function kindOf(base64: string): 'png' | 'jpeg' | 'gif' | 'webp' | null {
47  if (base64.startsWith('iVBORw0KGgo')) return 'png'
48  if (base64.startsWith('/9j/')) return 'jpeg'
49  if (base64.startsWith('R0lGOD')) return 'gif'
50  if (base64.startsWith('UklGR')) return 'webp'
51  return null
52}
53
54const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
55
56/** A PNG's width and height, read from its header. */
57export function pngSize(base64: string): { width: number; height: number } | null {
58  if (kindOf(base64) !== 'png' || base64.length < 32) return null
59  const bytes: number[] = []
60  for (let at = 0; at < 32; at += 4) {
61    const n = [0, 1, 2, 3].reduce((acc, i) => (acc << 6) | BASE64.indexOf(base64[at + i] ?? 'A'), 0)
62    bytes.push((n >> 16) & 255, (n >> 8) & 255, n & 255)
63  }
64  const word = (at: number) => (((bytes[at] ?? 0) << 24) | ((bytes[at + 1] ?? 0) << 16) | ((bytes[at + 2] ?? 0) << 8) | (bytes[at + 3] ?? 0)) >>> 0
65  const width = word(16)
66  const height = word(20)
67  return width > 0 && height > 0 ? { width, height } : null
68}
69
70/** How many rows a picture `columns` wide needs; a terminal cell is about twice as tall as wide. */
71export function rowsFor(width: number | null, height: number | null, columns: number, maxRows: number): number {
72  const rows = width !== null && height !== null ? Math.round((columns * height) / width / 2) : Math.round(columns / 3)
73  return Math.max(1, Math.min(maxRows, rows))
74}
75
76/** The base64 of every image block in a row's content, those inside a tool result included. */
77export function imagesIn(content: readonly Block[]): string[] {
78  const found: string[] = []
79  for (const block of content) {
80    if (block.type === 'image') {
81      const source = block.source as { type?: unknown; data?: unknown } | undefined
82      if (source?.type === 'base64' && typeof source.data === 'string') found.push(source.data)
83    } else if (block.type === 'tool_result' && Array.isArray(block.content)) {
84      found.push(...imagesIn(block.content as Block[]))
85    }
86  }
87  return found
88}
89
90/** `mcp__claude-in-chrome__computer` reads as `claude-in-chrome computer`. */
91export function toolLabel(tool: string): string {
92  return tool.startsWith('mcp__') ? tool.slice(5).split('__').join(' ') : tool
93}
94
95function baseName(path: string): string {
96  return path.split(/[\\/]/).pop() ?? path
97}
98
99function dirName(path: string): string {
100  const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
101  return at <= 0 ? path : path.slice(0, at)
102}
103
104/** "10:42" in local time. */
105function clockTime(ms: number): string {
106  const d = new Date(ms)
107  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
108}
109
110async function ran($: EngineInterface, argv: readonly string[], init?: { stdin?: string; env?: Record<string, string> }): Promise<boolean> {
111  const done = await $.process.run(argv, init).catch(() => null)
112  return done?.exitCode === 0
113}
114
115let platformOf: Promise<Platform> | null = null
116let userOf: Promise<string | null> | null = null
117
118function platform($: EngineInterface): Promise<Platform> {
119  platformOf ??= (async (): Promise<Platform> => {
120    if ((await $.env.get('WINDIR')) !== undefined) return 'windows'
121    const uname = await $.process.run(['uname', '-s']).catch(() => null)
122    return uname?.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
123  })()
124  return platformOf
125}
126
127function userId($: EngineInterface): Promise<string | null> {
128  userOf ??= $.process
129    .run(['id', '-u'])
130    .then(r => (r.exitCode === 0 ? r.stdout.trim() : null))
131    .catch(() => null)
132  return userOf
133}
134
135async function storeRoot($: EngineInterface): Promise<string | null> {
136  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
137  return home === undefined ? null : `${home}/.claude/show-and-tell`
138}
139
140async function sessionDir($: EngineInterface): Promise<string | null> {
141  const root = await storeRoot($)
142  if (root === null) return null
143  const dir = `${root}/${await $.session.id()}`
144  // `$.fs.write` makes the folders it needs; the shell copies below do not.
145  if (!(await $.fs.exists(dir))) await $.fs.write(`${dir}/.keep`, '')
146  return dir
147}
148
149let seq = 0
150
151async function newId($: EngineInterface, prefix: string): Promise<{ id: string; at: number }> {
152  const at = await $.clock.now()
153  seq += 1
154  return { id: `${prefix}-${at}-${seq}`, at }
155}
156
157async function writeBytes($: EngineInterface, path: string, base64: string): Promise<boolean> {
158  if ((await platform($)) === 'windows') {
159    const script = '[IO.File]::WriteAllBytes($env:SHOW_AND_TELL_OUT, [Convert]::FromBase64String([Console]::In.ReadToEnd()))'
160    return ran($, ['powershell', '-NoProfile', '-Command', script], { stdin: base64, env: { SHOW_AND_TELL_OUT: path } })
161  }
162  return ran($, ['sh', '-c', 'base64 --decode > "$1"', 'sh', path], { stdin: base64 })
163}
164
165async function copyFile($: EngineInterface, from: string, to: string): Promise<boolean> {
166  if ((await platform($)) === 'windows') {
167    const script = 'Copy-Item -LiteralPath $env:SHOW_AND_TELL_FROM -Destination $env:SHOW_AND_TELL_OUT'
168    return ran($, ['powershell', '-NoProfile', '-Command', script], { env: { SHOW_AND_TELL_FROM: from, SHOW_AND_TELL_OUT: to } })
169  }
170  return ran($, ['cp', from, to])
171}
172
173/** Writes the clipboard's picture to `path` as a PNG, with the tool each platform has. */
174async function clipboardTo($: EngineInterface, path: string): Promise<boolean> {
175  const os = await platform($)
176  if (os === 'mac') {
177    await ran($, [
178      'osascript',
179      '-e', 'on run argv',
180      '-e', 'set f to open for access (POSIX file (item 1 of argv)) with write permission',
181      '-e', 'set eof f to 0',
182      '-e', 'write (the clipboard as «class PNGf») to f',
183      '-e', 'close access f',
184      '-e', 'end run',
185      path,
186    ])
187  } else if (os === 'linux') {
188    const read = 'wl-paste --no-newline --type image/png > "$1" 2>/dev/null || xclip -selection clipboard -t image/png -o > "$1"'
189    await ran($, ['sh', '-c', read, 'sh', path])
190  } else {
191    const script =
192      'Add-Type -AssemblyName System.Windows.Forms; $i = [System.Windows.Forms.Clipboard]::GetImage(); if ($i) { $i.Save($env:SHOW_AND_TELL_OUT) } else { exit 1 }'
193    await ran($, ['powershell', '-NoProfile', '-STA', '-Command', script], { env: { SHOW_AND_TELL_OUT: path } })
194  }
195  const stat = await $.fs.stat(path).catch(() => null)
196  return stat !== null && stat.size > 0
197}
198
199/** Where Claude Code keeps a pasted image the moment it lands in the draft. Not an API: when it
200 * moves, the clipboard is read instead, and failing that the image is taken as the prompt is sent. */
201async function pasteCache($: EngineInterface, n: number): Promise<string | null> {
202  const uid = await userId($)
203  if (uid === null) return null
204  const base = (await $.env.get('CLAUDE_CODE_TMPDIR')) ?? '/tmp'
205  const project = (await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')
206  const path = `${base}/claude-${uid}/${project}/${await $.session.id()}/images/${n}.png`
207  return (await $.fs.exists(path)) ? path : null
208}
209
210async function sizeOf($: EngineInterface, png: string): Promise<{ width: number; height: number } | null> {
211  const stat = await $.fs.stat(png).catch(() => null)
212  if (stat === null || stat.size > MAX_READ) return null
213  const { base64 } = await $.fs.read(png, { as: 'bytes' })
214  return pngSize(base64)
215}
216
217/** A PNG of `file` the terminal can draw: the file itself when it is one, else one converted
218 * into `dir`. Null where this machine has no converter for it. */
219async function lookOf($: EngineInterface, file: string, dir: string, id: string): Promise<Look> {
220  const size = await sizeOf($, file).catch(() => null)
221  if (size !== null) return { thumb: file, ...size }
222  const os = await platform($)
223  let thumb: string | null = null
224  if (os === 'mac' && VECTOR.test(file)) {
225    const out = `${dir}/${id}`
226    if ((await ran($, ['mkdir', '-p', out])) && (await ran($, ['qlmanage', '-t', '-s', '800', '-o', out, file]))) {
227      thumb = `${out}/${baseName(file)}.png`
228    }
229  } else if (os === 'mac') {
230    const out = `${dir}/${id}.thumb.png`
231    if (await ran($, ['sips', '-s', 'format', 'png', file, '--out', out])) thumb = out
232  } else {
233    const out = `${dir}/${id}.thumb.png`
234    const first = VECTOR.test(file) ? `${file}[0]` : file
235    if ((await ran($, ['magick', first, out])) || (os === 'linux' && (await ran($, ['convert', first, out])))) thumb = out
236  }
237  if (thumb === null || !(await $.fs.exists(thumb))) return { thumb: null, width: null, height: null }
238  const converted = await sizeOf($, thumb).catch(() => null)
239  return { thumb, width: converted?.width ?? null, height: converted?.height ?? null }
240}
241
242let bandMs = 20_000
243
244async function saveManifest($: EngineInterface): Promise<void> {
245  const dir = await sessionDir($)
246  if (dir !== null) await $.fs.write(`${dir}/manifest.json`, JSON.stringify(await read($, shots)))
247}
248
249async function loadManifest($: EngineInterface): Promise<void> {
250  const root = await storeRoot($)
251  const path = root === null ? null : `${root}/${await $.session.id()}/manifest.json`
252  const saved = path !== null && (await $.fs.exists(path)) ? (JSON.parse(await $.fs.read(path)) as Shot[]) : []
253  const kept: Shot[] = []
254  for (const shot of saved) {
255    if (!(await $.fs.exists(shot.path))) continue
256    const hasThumb = shot.thumb !== null && (await $.fs.exists(shot.thumb))
257    kept.push(hasThumb ? shot : { ...shot, thumb: null })
258  }
259  await $.state.set(SHOTS, kept)
260}
261
262async function addShot($: EngineInterface, shot: Shot, isFresh: boolean): Promise<void> {
263  await update($, shots, list => {
264    const same = (s: Shot) => s.id === shot.id || (shot.source !== 'you' && s.source !== 'you' && s.path === shot.path)
265    // Claude often reads back a picture it just made; it stays one Claude made.
266    const wasMade = shot.source === 'claude-read' && list.some(s => same(s) && s.source === 'claude-made')
267    return [{ ...shot, source: wasMade ? 'claude-made' : shot.source }, ...list.filter(s => !same(s))].slice(0, MAX_SHOTS)
268  })
269  await saveManifest($)
270  if (!isFresh) return
271  await update($, fresh, ids => [shot.id, ...ids.filter(id => id !== shot.id)].slice(0, 8))
272  $.clock.after(bandMs, () => void update($, fresh, ids => ids.filter(id => id !== shot.id)).catch(() => undefined))
273}
274
275/** Takes a paste the moment its `[Image #N]` shows in the draft. */
276async function capturePaste($: EngineInterface, n: number): Promise<void> {
277  const known = (await read($, shots)).some(s => s.source === 'you' && s.imageNo === n && s.isSent !== true)
278  if (known) return
279  const dir = await sessionDir($)
280  if (dir === null) return
281  const { id, at } = await newId($, 'paste')
282  const path = `${dir}/${id}.png`
283  const cached = await pasteCache($, n).catch(() => null)
284  const isTaken = cached !== null ? await copyFile($, cached, path) : await clipboardTo($, path)
285  if (!isTaken) return
286  const look = await lookOf($, path, dir, id)
287  await addShot($, { id, source: 'you', label: `Pasted image #${n}`, path, ...look, at, imageNo: n }, true)
288}
289
290async function saveBytes($: EngineInterface, base64: string, source: ShotSource, label: string, isFresh: boolean, imageNo?: number): Promise<void> {
291  const kind = kindOf(base64)
292  if (kind === null) return
293  const dir = await sessionDir($)
294  if (dir === null) return
295  const { id, at } = await newId($, source)
296  const path = `${dir}/${id}.${kind === 'jpeg' ? 'jpg' : kind}`
297  if (!(await writeBytes($, path, base64))) return
298  const size = pngSize(base64)
299  const look = size !== null ? { thumb: path, ...size } : await lookOf($, path, dir, id)
300  await addShot($, { id, source, label, path, ...look, at, ...(imageNo !== undefined ? { imageNo, isSent: true } : {}) }, isFresh)
301}
302
303/** A prompt's images as it is stored: a paste already taken is marked sent, any other is kept now. */
304async function takePrompt($: EngineInterface, images: readonly string[], numbers: readonly number[]): Promise<void> {
305  for (const [index, base64] of images.entries()) {
306    const n = numbers[index]
307    const list = await read($, shots)
308    const taken = list.find(s => s.source === 'you' && s.imageNo === n && s.isSent !== true)
309    if (taken !== undefined) {
310      await update($, shots, all => all.map(s => (s.id === taken.id ? { ...s, isSent: true } : s)))
311      continue
312    }
313    await saveBytes($, base64, 'you', n === undefined ? 'Sent image' : `Image #${n}`, false, n)
314  }
315  await saveManifest($)
316}
317
318/** A file Claude read or made, shown where it lies; only a converted thumbnail is the plugin's. */
319async function keepFile($: EngineInterface, file: string, source: ShotSource): Promise<void> {
320  const dir = await sessionDir($)
321  if (dir === null) return
322  const { id, at } = await newId($, source)
323  const look = await lookOf($, file, dir, id)
324  await addShot($, { id, source, label: baseName(file), path: file, ...look, at }, true)
325}
326
327/** Pictures a shell command wrote: the working directory and the folders just inside it. */
328async function scanMade($: EngineInterface, since: number): Promise<void> {
329  const cwd = await $.session.cwd()
330  const root = await storeRoot($)
331  const top = await $.fs.list(cwd).catch(() => [])
332  const dirs = [cwd, ...top.filter(e => e.kind === 'dir' && !e.name.startsWith('.') && e.name !== 'node_modules').map(e => `${cwd}/${e.name}`)]
333  const made: string[] = []
334  for (const dir of dirs.slice(0, SCAN_DIRS)) {
335    const entries = dir === cwd ? top : await $.fs.list(dir).catch(() => [])
336    for (const entry of entries) {
337      const path = `${dir}/${entry.name}`
338      if (entry.kind === 'file' && PICTURE.test(entry.name) && entry.mtimeMs >= since && (root === null || !path.startsWith(root))) made.push(path)
339    }
340  }
341  for (const path of made.slice(0, 12)) await keepFile($, path, 'claude-made')
342}
343
344/** Removes kept images older than a week. `$.fs` cannot delete, so this asks `find`; where there is
345 * no `find` (Windows) the call fails and the images stay. */
346async function prune($: EngineInterface): Promise<void> {
347  const root = await storeRoot($)
348  if (root === null || !(await $.fs.exists(root))) return
349  await $.process.run(['find', root, '-type', 'f', '-mtime', `+${KEEP_DAYS}`, '-delete'])
350  await $.process.run(['find', root, '-mindepth', '1', '-type', 'd', '-empty', '-delete'])
351}
352
353let seen: number[] = []
354let isPolling = false
355// The image numbers of the prompt being sent, matched in order to the image blocks it is stored with.
356let sending: number[] = []
357
358async function poll($: EngineInterface): Promise<void> {
359  if (isPolling) return
360  isPolling = true
361  try {
362    const now = imageNumbers((await $.prompt.read()).text)
363    const added = now.filter(n => !seen.includes(n))
364    seen = now
365    for (const n of added) await capturePaste($, n)
366  } finally {
367    isPolling = false
368  }
369}
370
371async function openFile($: EngineInterface, path: string): Promise<void> {
372  const os = await platform($)
373  const argv = os === 'mac' ? ['open', path] : os === 'linux' ? ['xdg-open', path] : ['cmd', '/c', 'start', '', path]
374  if (!(await ran($, argv))) $.ui.toast(`📸 Could not open ${path}`)
375}
376
377async function showInFolder($: EngineInterface, path: string): Promise<void> {
378  const os = await platform($)
379  const argv = os === 'mac' ? ['open', '-R', path] : os === 'linux' ? ['xdg-open', dirName(path)] : ['explorer', `/select,${path}`]
380  // Explorer exits 1 even when it opens the folder.
381  if (!(await ran($, argv)) && os !== 'windows') $.ui.toast(`📸 Could not open ${dirName(path)}`)
382}
383
384async function copyPath($: EngineInterface, path: string, surface: 'terminal' | 'desktop' | 'mobile' | 'vscode'): Promise<void> {
385  const copied = await $.ui.copy({ text: path, surface })
386  $.ui.toast(copied.isCopied ? '📸 Path copied' : `📸 ${path}`)
387}
388
389async function insertPath($: EngineInterface, path: string): Promise<void> {
390  await $.ui.close({ id: PANE })
391  await $.prompt.fill({ text: `@${path} `, mode: 'insert' })
392}
393
394async function dropPastes($: EngineInterface): Promise<void> {
395  const list = await read($, shots)
396  await update($, fresh, ids => ids.filter(id => list.find(s => s.id === id)?.source !== 'you'))
397}
398
399async function reset($: EngineInterface): Promise<void> {
400  seen = []
401  await $.state.set(SHOTS, [])
402  await $.state.set(FRESH, [])
403  await $.state.set(PICKED, null)
404}
405
406// The hint line under the prompt is shared. Each Desk Neighbours mod adds one clickable item to
407// a row keyed `desk-hint` after the engine's own hint; items are kept in order by their keys
408// (`desk-1-…` to `desk-6-…`), so the row reads the same alone or together, in any load order.
409const DESK = 'desk-hint'
410
411// The engine draws the band's collapse mark `[-]` over the right end of its first row without
412// narrowing `bodyColumns`, so band rows stop this many cells short of the edge.
413const MARKER = 4
414
415function keyOf(node: RenderNode | undefined): string {
416  if (typeof node !== 'object' || node === null || !('props' in node)) return ''
417  const key = (node.props as Record<string, unknown> | undefined)?.key
418  return typeof key === 'string' ? key : ''
419}
420
421export function joinDesk(below: RenderElement, mine: RenderElement, wrap: (children: RenderNode[]) => RenderElement): RenderElement {
422  if (below.type !== 'Box' || keyOf(below) !== DESK) return wrap([below, mine])
423  const [line, ...items] = below.children ?? []
424  const sorted = [...items.filter(n => keyOf(n) !== keyOf(mine)), mine].sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
425  return { ...below, children: line === undefined ? sorted : [line, ...sorted] }
426}
427
428/** Opens the gallery. Inline it would get a third of the height; a picture needs more. */
429async function openGallery($: EngineInterface): Promise<void> {
430  await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, rows: GALLERY_ROWS, columns: GALLERY_COLUMNS })
431}
432
433/** Opens the gallery, or closes it when it is the one showing. */
434async function togglePane($: EngineInterface): Promise<void> {
435  const pane = (await $.ui.panes()).find(p => p.id === PANE)
436  if (pane?.isShown === true) {
437    await $.ui.close({ id: PANE })
438    return
439  }
440  await openGallery($)
441}
442
443/** The box a picture fills: `columns` wide unless that would make it taller than `maxRows`,
444 * then as wide as `maxRows` allows, keeping its shape. */
445export function fitPicture(width: number | null, height: number | null, columns: number, maxRows: number): { columns: number; rows: number } {
446  const rows = rowsFor(width, height, columns, maxRows)
447  if (width === null || height === null || Math.round((columns * height) / width / 2) <= maxRows) return { columns, rows }
448  return { columns: Math.max(1, Math.min(columns, Math.round((maxRows * 2 * width) / height))), rows: maxRows }
449}
450
451function sizeText(shot: Shot): string {
452  return shot.width !== null && shot.height !== null ? ` · ${shot.width}×${shot.height}` : ''
453}
454
455const SOURCE_WORD: Record<ShotSource, string> = { you: 'you pasted', 'claude-read': 'Claude looked at', 'claude-made': 'Claude made' }
456
457export const register: Register = (on, options) => {
458  bandMs = Number(options.bandSeconds ?? 20) * 1000
459
460  on('session.start', async ($, e, next) => {
461    await $.command.register({ name: 'gallery', description: '📸 Every image you and Claude passed around this session' })
462    void loadManifest($).catch(() => undefined)
463    void prune($).catch(() => undefined)
464    if (e.isInteractive) $.clock.every(POLL_MS, () => void poll($).catch(() => undefined))
465    return next(e)
466  })
467
468  on('command.run', { command: 'gallery' }, async $ => {
469    await openGallery($)
470    return { text: '📸 Gallery open. Esc closes.' }
471  })
472
473  on('prompt.submit', ($, e, next) => {
474    sending = imageNumbers(e.text)
475    void dropPastes($).catch(() => undefined)
476    return next(e)
477  })
478
479  // The image bytes first reach a plugin here, as the prompt or a tool's result is stored. The row
480  // passes on untouched; what it carries is copied out beside it.
481  on('session.append', { door: 'prompt' }, ($, e, next) => {
482    const images = imagesIn(e.message.content)
483    if (images.length > 0) {
484      const numbers = sending
485      sending = []
486      void takePrompt($, images, numbers).catch(() => undefined)
487    }
488    return next(e)
489  })
490
491  on('session.append', { door: 'tool-result' }, ($, e, next) => {
492    // A Read keeps the file it read (below); every other tool's picture exists only here.
493    const tool = e.origin.kind === 'tool' && 'tool' in e.origin ? e.origin.tool : null
494    if (tool !== null && tool !== 'Read') {
495      for (const base64 of imagesIn(e.message.content)) {
496        void saveBytes($, base64, 'claude-read', toolLabel(tool), true).catch(() => undefined)
497      }
498    }
499    return next(e)
500  })
501
502  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
503    const done = await next(e)
504    if (done.deny === undefined && done.isError !== true && PICTURE.test(e.file_path)) {
505      void keepFile($, e.file_path, 'claude-read').catch(() => undefined)
506    }
507    return done
508  })
509
510  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
511    const done = await next(e)
512    if (done.deny === undefined && done.isError !== true && PICTURE.test(e.file_path)) {
513      void keepFile($, e.file_path, 'claude-made').catch(() => undefined)
514    }
515    return done
516  })
517
518  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
519    const done = await next(e)
520    if (done.deny === undefined && done.isError !== true && PICTURE.test(e.file_path)) {
521      void keepFile($, e.file_path, 'claude-made').catch(() => undefined)
522    }
523    return done
524  })
525
526  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
527    const since = await $.clock.now()
528    const done = await next(e)
529    if (done.deny === undefined) void scanMade($, since).catch(() => undefined)
530    return done
531  })
532
533  // `/clear` and `/resume` go on under a new session with empty state and fire no `session.start`;
534  // their classic SessionStart (source `clear` or `resume`) is the one place to load the gallery again.
535  on('classic.SessionStart', ($, e, next) => {
536    if (e.source === 'clear' || e.source === 'resume') void loadManifest($).catch(() => undefined)
537    return next(e)
538  })
539
540  on('session.end', async ($, e, next) => {
541    if (e.reason === 'clear' || e.reason === 'resume') await reset($).catch(() => undefined)
542    return next(e)
543  })
544
545  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
546    if (e.props.hasSurvey) return next(e)
547    const ids = await read($, fresh)
548    const list = await read($, shots)
549    const showing = ids.map(id => list.find(s => s.id === id)).filter((s): s is Shot => s !== undefined)
550    if (showing.length === 0) return next(e)
551    const below = await next(e)
552    const width = e.props.bodyColumns - MARKER
553    const fits = Math.max(1, Math.floor((width - 22) / (THUMB_COLUMNS + 2)))
554    const thumbRows = e.props.maxRows - 2
555    if (e.surface === 'terminal' && thumbRows >= 3) {
556      const { Box, Text, Button, Image } = $.ui.resolve(e)
557      return (
558        <Box flexDirection="column">
559          {below}
560          <Box flexDirection="row" columnGap={2} width={width}>
561            {showing.slice(0, fits).map(shot => (
562              <Box flexDirection="column" width={THUMB_COLUMNS} flexShrink={0}>
563                {/* The label is under it, so where the terminal draws no pixels the alt says the size. */}
564                {shot.thumb !== null && (
565                  <Image
566                    key={`band-${shot.id}`}
567                    source={{ file: shot.thumb, format: 'png', generation: shot.at }}
568                    columns={THUMB_COLUMNS}
569                    rows={rowsFor(shot.width, shot.height, THUMB_COLUMNS, thumbRows)}
570                    alt={shot.width !== null && shot.height !== null ? `${shot.width}×${shot.height}` : 'picture'}
571                  />
572                )}
573                <Text dimColor wrap="truncate-end">
574                  {ICON[shot.source]} {shot.label}
575                </Text>
576              </Box>
577            ))}
578            <Box flexDirection="column" flexShrink={0}>
579              {showing.length > fits && <Text dimColor>+{showing.length - fits} more</Text>}
580              <Button key="band-gallery" plain label="Gallery" onPress={() => void openGallery($)} />
581              <Button key="band-dismiss" plain dimColor label="Dismiss" onPress={() => void $.state.set(FRESH, [])} />
582            </Box>
583          </Box>
584        </Box>
585      )
586    }
587    const { Box, Text, Button } = $.ui.resolve(e)
588    const [first] = showing
589    return (
590      <Box flexDirection="column">
591        {below}
592        <Box flexDirection="row" flexWrap="wrap" columnGap={2} width={width}>
593          <Box flexShrink={1}>
594            <Text>
595              📸 {first === undefined ? '' : `${ICON[first.source]} ${first.label}${sizeText(first)}`}
596              {showing.length > 1 ? ` and ${showing.length - 1} more` : ''}
597            </Text>
598          </Box>
599          <Box flexShrink={0}>
600            <Button key="band-gallery" plain label="Gallery" onPress={() => void openGallery($)} />
601          </Box>
602        </Box>
603      </Box>
604    )
605  })
606
607  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
608    const n = (await read($, shots)).length
609    const label = n === 0 ? '📸 gallery' : `📸 ${n}`
610    const below = await next(e)
611    const { Box, Button } = $.ui.resolve(e)
612    const mine = <Button key="desk-6-show-and-tell" plain dimColor label={label} onPress={() => void togglePane($)} />
613    return joinDesk(below, mine, children => (
614      <Box key={DESK} flexDirection="row" columnGap={2}>
615        {children}
616      </Box>
617    ))
618  })
619
620  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
621    const all = await read($, shots)
622    const which = await read($, filter)
623    const pickedId = await read($, picked)
624    const list = all.filter(s => which === 'all' || (which === 'you' ? s.source === 'you' : s.source !== 'you'))
625    const current = list.find(s => s.id === pickedId) ?? list[0]
626    const { bodyColumns } = e.props
627    const bodyRows = e.props.scroll.bodyRows
628    const isBeside = bodyColumns >= SIDE_BY_SIDE
629    // Every row is counted: an inline pane may get far fewer than it asked for, and what does not
630    // fit is cut off. The header (with the filters) takes 2, the footer 2 where there is room.
631    const hasFooter = bodyRows >= 14
632    const room = Math.max(3, bodyRows - 2 - (hasFooter ? 2 : 0))
633    // Beside the picture: the details (3 rows and a gap), the list, and a "+N more" line if needed.
634    const listFits = (rows: number) => (list.length <= rows ? list.length : Math.max(1, rows - 1))
635    const listRows = isBeside ? listFits(room - 4) : listFits(Math.min(3, Math.max(1, room - 7)))
636    const box = fitPicture(
637      current?.width ?? null,
638      current?.height ?? null,
639      isBeside ? Math.min(PREVIEW_COLUMNS, Math.floor(bodyColumns * 0.45)) : Math.min(bodyColumns - 2, PREVIEW_COLUMNS),
640      isBeside ? room : room - 4 - listRows - (list.length > listRows ? 1 : 0),
641    )
642    const hasPicture = current?.thumb != null && e.surface === 'terminal' && box.rows >= 3
643    const tabs = (['all', 'you', 'claude'] as const).map(f => ({ f, label: f === 'all' ? 'All' : f === 'you' ? 'You' : 'Claude', hotkey: f[0] }))
644    const { Box, Text, Button } = $.ui.resolve(e)
645    const preview =
646      hasPicture && current?.thumb != null && e.surface === 'terminal'
647        ? (() => {
648            const { Image } = $.ui.resolve(e)
649            return (
650              <Box flexShrink={0} marginRight={isBeside ? 2 : 0}>
651                <Image
652                  key="preview"
653                  source={{ file: current.thumb, format: 'png', generation: current.at }}
654                  columns={box.columns}
655                  rows={box.rows}
656                  alt="This terminal shows no pictures; o opens it"
657                />
658              </Box>
659            )
660          })()
661        : null
662    const details = current !== undefined && (
663      <Box flexDirection="column" marginBottom={1}>
664        <Text bold wrap="truncate-end">
665          {ICON[current.source]} {current.label}
666          {sizeText(current)}
667        </Text>
668        <Text dimColor wrap="truncate-end">
669          {SOURCE_WORD[current.source]} at {clockTime(current.at)} · {current.path}
670        </Text>
671        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
672          <Button key="act-open" hotkey="o" plain label="Open" autoFocus onPress={() => void openFile($, current.path)} />
673          <Button key="act-insert" hotkey="i" plain label="Insert @path" onPress={() => void insertPath($, current.path)} />
674          <Button key="act-copy" hotkey="p" plain label="Copy path" onPress={press => void copyPath($, current.path, press.surface)} />
675          <Button key="act-folder" hotkey="f" plain label="Show in folder" onPress={() => void showInFolder($, current.path)} />
676        </Box>
677      </Box>
678    )
679    const picks = list.slice(0, listRows).map(shot => (
680      <Button
681        key={`pick-${shot.id}`}
682        plain
683        dimColor={shot.id !== current?.id}
684        label={`${shot.id === current?.id ? '›' : ' '} ${clockTime(shot.at)}  ${ICON[shot.source]} ${shot.label}${sizeText(shot)}`}
685        onPress={() => void $.state.set(PICKED, shot.id)}
686      />
687    ))
688    const more = list.length > picks.length ? <Text dimColor>  +{list.length - picks.length} more</Text> : null
689    return (
690      <Box flexDirection="column">
691        <Box flexDirection="row" flexWrap="wrap" columnGap={3} marginBottom={1}>
692          <Box flexDirection="row">
693            <Text bold>📸 Show & Tell</Text>
694            <Text dimColor>
695              {' '}
696              · {all.length} image{all.length === 1 ? '' : 's'} this session
697            </Text>
698          </Box>
699          <Box flexDirection="row" columnGap={2}>
700            {tabs.map(t => (
701              <Button key={`tab-${t.f}`} hotkey={t.hotkey} plain dimColor={t.f !== which} label={t.label} onPress={() => void $.state.set(FILTER, t.f)} />
702            ))}
703          </Box>
704        </Box>
705        {current === undefined && <Text dimColor>Nothing yet. Paste an image with ctrl+v, or let Claude read or make one.</Text>}
706        {current !== undefined && isBeside && (
707          <Box flexDirection="row">
708            {preview}
709            <Box flexDirection="column" flexGrow={1} flexShrink={1}>
710              {details}
711              {picks}
712              {more}
713            </Box>
714          </Box>
715        )}
716        {current !== undefined && !isBeside && (
717          <Box flexDirection="column">
718            {preview}
719            {details}
720            {picks}
721            {more}
722          </Box>
723        )}
724        {hasFooter && (
725          <Box marginTop={1}>
726            <Text dimColor>Kept {KEEP_DAYS} days · tab walks the list · esc closes</Text>
727          </Box>
728        )}
729      </Box>
730    )
731  })
732}
733
types/index.d.ts 35 lines
1/** Who brought an image into the session: you pasted it, Claude looked at it, or Claude made it. */
2export type ShotSource = 'you' | 'claude-read' | 'claude-made'
3
4/** One image of the session, for the band and the gallery. */
5export type Shot = {
6  id: string
7  source: ShotSource
8  label: string
9  /** The file Open, Copy path and Insert use: the plugin's own copy, or the file Claude read or made. */
10  path: string
11  /** A PNG the terminal can draw, or null where none could be made (the gallery lists it as text). */
12  thumb: string | null
13  width: number | null
14  height: number | null
15  at: number
16  /** The `[Image #N]` a paste came in as. */
17  imageNo?: number
18  /** A paste that has gone out with a prompt. */
19  isSent?: boolean
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'show-and-tell': {
25      /** Newest first. */
26      shots: Shot[]
27      /** Ids the band above the prompt shows. */
28      fresh: string[]
29      /** The gallery's selected shot. */
30      picked: string | null
31      filter: 'all' | 'you' | 'claude'
32    }
33  }
34}
35