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

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

/plugin install show-and-tell@claude-desk-neighbours
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.
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 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.
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.
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.
hooks/register.tsx 733 lines1import { 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}
733types/index.d.ts 35 lines1/** 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