/image-preview opens a pane listing every image of the session, pasted or returned by a tool, and shows the one you pick.

/image-preview opens a pane beside the conversation with the images of the session: the ones you pasted, the ones tools returned (screenshots, Read of an image, MCP results), in the main conversation and in subagents, and image files written during the session. Pick one to see it right under its row, with a few lines of the message it came with.
1–9, or the arrows and Enter, pick an image; Esc closes the pane.e, to enlarge it across the terminal; click it again, or press e or Esc, to go back to the list. o opens it in your system's image viewer. While enlarged, closing the pane any other way also goes back to the list first.sips (macOS) or ImageMagick./plugin marketplace add scoobynko/claude-code-mods
/plugin install image-preview@scoobynko-mods
The list is read from the conversation itself, so nothing is copied until you look. The image you pick is written as a PNG to a temp folder for the session, where the terminal reads it, and the folder is removed when the session ends.
Image files on disk are found by name: a path that a shell command or an MCP tool was given, one a command printed, or one Claude mentioned, kept when the file exists and was written since the session began. Quoted or escaped paths with spaces work, and so does a relative path after a cd. A file that nothing names (a script that picks its own filename and prints nothing) is not found until Claude reads or mentions it.
Before a compaction the images are saved to the temp folder, so they stay in the list afterwards. SVG files are not shown.
claude plugin test plugins/image-previewhooks/register.tsx 430 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ImageFile, ImageItem } from '../types'
5import { arrivals, collect, imageId, pathId } from './collect'
6import type { Block, Found, Message, Pending } from './collect'
7import { fit, pngSize } from './picture'
8
9const PANE = 'images'
10const MAIN = ''
11const LIST_ROWS = 8
12const FRAGMENT_ROWS = 3
13const CHROME_ROWS = 3
14const TRANSCRIPT_COLUMNS = 24
15const MIN_PICTURE_ROWS = 4
16const NO_CONVERTER = 2
17const NOT_CONVERTED = 3
18const MTIME_SLACK_MS = 2000
19const MATERIALIZE = [
20 'umask 077',
21 'mkdir -p "$1" || exit 1',
22 'if [ "$4" = decode ]; then base64 --decode > "$2" || exit 1; fi',
23 'if [ "$(head -c 8 "$2" | base64)" = iVBORw0KGgo= ]; then',
24 ' if [ "$4" = decode ]; then mv "$2" "$3" || exit 1; out=$3; else out=$2; fi',
25 'else',
26 ' command -v sips >/dev/null 2>&1 || command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 || exit 2',
27 ' sips -s format png "$2" --out "$3" >/dev/null 2>&1 || magick "$2[0]" "$3" 2>/dev/null || convert "$2[0]" "$3" 2>/dev/null || exit 3',
28 ' if [ "$4" = decode ]; then rm -f "$2"; fi',
29 ' out=$3',
30 'fi',
31 'head -c 24 "$out" | base64',
32 'printf "%s\\n" "$out"',
33].join('\n')
34
35const VIEW = 'case "$(uname)" in Darwin) open "$1" ;; *) xdg-open "$1" >/dev/null 2>&1 & ;; esac'
36
37const items = atom({ plugin: 'image-preview', key: 'items' } as const, [])
38const selected = atom({ plugin: 'image-preview', key: 'selected' } as const, '')
39const files = atom({ plugin: 'image-preview', key: 'files' } as const, {})
40const seen = atom({ plugin: 'image-preview', key: 'seen' } as const, {})
41const agents = atom({ plugin: 'image-preview', key: 'agents' } as const, [])
42const zoomed = atom({ plugin: 'image-preview', key: 'zoomed' } as const, false)
43
44type Dated = Found & { at?: number; agentId?: string; version?: number }
45type Source = { id: string } & ({ kind: 'block'; mediaType: string; data: string } | { kind: 'path'; path: string })
46type Roots = { cwd: string; home: string; started: number }
47
48const pending: Pending = new Map()
49const noted = new Set<string>()
50const stale = new Set<string>()
51let queue: Promise<unknown> = Promise.resolve()
52let isRefreshQueued = false
53let screen = { columns: 0, rows: 0 }
54let ended = 0
55
56function inOrder<T>(task: () => Promise<T>): Promise<T> {
57 const run = queue.then(task, task)
58 queue = run.then(
59 () => undefined,
60 () => undefined,
61 )
62 return run
63}
64
65const clockTime = (at: number) => new Date(at).toTimeString().slice(0, 5)
66
67const timeOf = (item: ImageItem) => item.at ?? item.file?.version ?? Number.NEGATIVE_INFINITY
68
69const byTime = (a: ImageItem, b: ImageItem) => (timeOf(a) === timeOf(b) ? 0 : timeOf(a) < timeOf(b) ? -1 : 1)
70
71const isReady = (file: ImageFile | undefined, version: number | undefined) => file !== undefined && file.version === version
72
73const itemOf = (one: Dated): ImageItem => ({
74 id: one.id,
75 label: one.label,
76 fragment: one.fragment,
77 at: one.at,
78 agentId: one.agentId,
79 file: one.kind === 'path' && one.version !== undefined ? { path: one.path, version: one.version } : undefined,
80})
81
82const under = (base: string, path: string, roots: Roots) =>
83 path.startsWith('/')
84 ? path
85 : path === '~' || path.startsWith('~/')
86 ? `${roots.home}${path.slice(1)}`
87 : `${base}/${path.replace(/^\.\//, '')}`
88
89const spellings = (path: string, bases: readonly string[], roots: Roots) => [
90 ...new Set([...bases.map(base => under(roots.cwd, base, roots)), roots.cwd].map(base => under(base, path, roots))),
91]
92
93async function rootsOf($: EngineInterface): Promise<Roots> {
94 const [usage, cwd, home] = await Promise.all([$.session.usage().catch(() => undefined), $.session.cwd(), $.env.get('HOME')])
95 return { cwd, home: home ?? '~', started: (usage?.startedAt ?? 0) - MTIME_SLACK_MS }
96}
97
98async function placed($: EngineInterface, path: string, bases: readonly string[], roots: Roots) {
99 const tried = spellings(path, bases, roots)
100 const stats = await Promise.all(tried.map(spelling => $.fs.stat(spelling, { resolve: true }).catch(() => undefined)))
101 const at = stats.findIndex(stat => stat?.kind === 'file' && stat.mtimeMs >= roots.started)
102 const stat = stats[at]
103 return stat ? { path: stat.realPath ?? tried[at] ?? path, version: stat.mtimeMs } : undefined
104}
105
106async function conversation($: EngineInterface, agentId: string | undefined): Promise<readonly Message[]> {
107 if (agentId === undefined) return $.session.messages({ as: 'api' })
108 const answer = await $.session.messages({ as: 'api', agentId })
109 return Array.isArray(answer) ? answer : []
110}
111
112async function scan($: EngineInterface, agentId?: string, known?: Roots): Promise<Dated[]> {
113 const [stamps, roots, messages] = await Promise.all([read($, seen), known ?? rootsOf($), conversation($, agentId)])
114 const found = collect(messages)
115 const places = await Promise.all(found.map(one => (one.kind === 'path' ? placed($, one.path, one.bases, roots) : undefined)))
116 const dated = new Map<string, Dated>()
117
118 found.forEach((one, i) => {
119 if (one.kind === 'block') {
120 dated.set(one.id, { ...one, agentId, at: stamps[one.id] })
121 return
122 }
123 const file = places[i]
124 if (!file) return
125 const id = pathId(file.path)
126 if (dated.has(id) && one.from === 'text') return
127 dated.delete(id)
128 dated.set(id, { ...one, agentId, id, ...file })
129 })
130
131 return [...dated.values()]
132}
133
134async function everyone($: EngineInterface) {
135 const [listed, remembered] = await Promise.all([$.agent.list().catch(() => []), read($, agents)])
136 return [MAIN, ...new Set([...remembered, ...listed.map(agent => agent.id)])]
137}
138
139async function readFiles($: EngineInterface, found: readonly Dated[]) {
140 const read = [...new Set(found.flatMap(one => (one.kind === 'block' && one.readFrom ? [one.readFrom] : [])))]
141 const real = await Promise.all(read.map(path => $.fs.stat(path, { resolve: true }).then(stat => stat.realPath ?? path, () => path)))
142 return new Set(real.map(pathId))
143}
144
145async function relist($: EngineInterface, scope?: readonly string[]) {
146 const [roots, before, ids] = await Promise.all([rootsOf($), read($, items), scope ?? everyone($)])
147 const scanned = (await Promise.all(ids.map(id => scan($, id === MAIN ? undefined : id, roots)))).flat()
148 const hasFiles = before.some(item => item.file) || scanned.some(one => one.kind === 'path')
149 const alreadyRead = hasFiles ? await readFiles($, scanned) : new Set<string>()
150 const found = scanned.filter(one => !alreadyRead.has(one.id))
151 const fresh = new Map(found.map(one => [one.id, itemOf(one)]))
152 const merged = [...before.filter(item => !fresh.has(item.id) && !alreadyRead.has(item.id)), ...fresh.values()].sort(byTime)
153 if (JSON.stringify(merged) !== JSON.stringify(before)) await update($, items, () => merged)
154
155 return { found, before, merged }
156}
157
158async function tempDir($: EngineInterface, sessionId?: string) {
159 const [base, id] = await Promise.all([$.env.get('TMPDIR'), sessionId ?? $.session.id()])
160 return `${(base ?? '/tmp').replace(/\/+$/, '')}/claude-image-preview-${id}`
161}
162
163async function materialize($: EngineInterface, one: Source, version: number | undefined): Promise<ImageFile> {
164 const dir = await tempDir($)
165 const plan =
166 one.kind === 'path'
167 ? { source: one.path, mode: 'keep', init: {} }
168 : { source: `${dir}/${one.id}.src`, mode: 'decode', init: { stdin: one.data } }
169
170 try {
171 const ran = await $.process.run(['sh', '-c', MATERIALIZE, 'sh', dir, plan.source, `${dir}/${one.id}.png`, plan.mode], plan.init)
172 if (ran.exitCode === NO_CONVERTER) return { error: 'Showing this image needs sips or ImageMagick to convert it to PNG.', version }
173 if (ran.exitCode === NOT_CONVERTED) return { error: 'This image could not be converted to PNG.', version }
174 const [header = '', file = ''] = ran.stdout.split('\n')
175 const size = ran.exitCode === 0 ? pngSize(header) : undefined
176 return size && file ? { file, ...size, version } : { error: 'This image could not be prepared for preview.', version }
177 } catch {
178 return { error: 'This image could not be prepared: no shell (macOS or Linux only), or it took too long.', version }
179 }
180}
181
182async function prepare($: EngineInterface, id: string, one: Source | undefined, version: number | undefined) {
183 const file: ImageFile = one ? await materialize($, one, version) : { error: 'This image is no longer in the conversation.', version }
184 await update($, files, all => ({ ...all, [id]: file }))
185}
186
187async function show($: EngineInterface, id: string, found?: Dated[], isRetry = false) {
188 const [list, ready, current] = await Promise.all([read($, items), read($, files), read($, selected)])
189 if (current !== id) await update($, selected, () => id)
190 const item = list.find(other => other.id === id)
191 const version = item?.file?.version
192 const file = ready[id]
193 if (isReady(file, version) && !(isRetry && file !== undefined && 'error' in file)) return
194
195 const source: Source | undefined = item?.file
196 ? { kind: 'path', id, path: item.file.path }
197 : (found?.find(one => one.id === id) ?? (await scan($, item?.agentId)).find(one => one.id === id))
198 await prepare($, id, source, version)
199}
200
201async function refresh($: EngineInterface, scope?: readonly string[]) {
202 const { found, before, merged } = await relist($, scope)
203 const current = await read($, selected)
204 const newest = merged.at(-1)
205 const isFollowing = scope === undefined || current === '' || current === before.at(-1)?.id
206 if (newest && isFollowing) await show($, newest.id, found)
207 else if (current) await show($, current, found)
208
209 return merged.length
210}
211
212async function refreshStale($: EngineInterface) {
213 const scope = [...stale]
214 stale.clear()
215 isRefreshQueued = false
216 const panes = await $.ui.panes()
217 if (panes.some(pane => pane.id === PANE)) await refresh($, scope)
218}
219
220async function keep($: EngineInterface, found: Dated[]) {
221 const startedIn = ended
222 const ready = await read($, files)
223 for (const one of found) {
224 if (ended !== startedIn) return
225 if (one.kind === 'block' && !isReady(ready[one.id], undefined)) await prepare($, one.id, one, undefined)
226 }
227}
228
229async function refreshSoon($: EngineInterface, scope: string) {
230 stale.add(scope)
231 if (isRefreshQueued) return
232 isRefreshQueued = true
233 await inOrder(() => refreshStale($))
234}
235
236async function end($: EngineInterface, sessionId: string) {
237 const [wasZoomed, panes] = await Promise.all([read($, zoomed), $.ui.panes().catch(() => [])])
238 await forget($)
239 if (wasZoomed && panes.some(pane => pane.id === PANE)) await resize($, false)
240 await $.process.run(['rm', '-rf', await tempDir($, sessionId)]).catch(() => undefined)
241}
242
243async function notice($: EngineInterface, role: string | undefined, blocks: readonly Block[], agentId: string | undefined) {
244 if (agentId !== undefined && !noted.has(agentId)) {
245 noted.add(agentId)
246 await update($, agents, all => (all.includes(agentId) ? all : [...all, agentId]))
247 }
248 const { images, isNews } = arrivals(role, blocks, pending)
249 if (images.length > 0) {
250 const now = await $.clock.now()
251 await update($, seen, all => ({ ...all, ...Object.fromEntries(images.map(one => [imageId(one.data), now])) }))
252 }
253 return isNews
254}
255
256async function resize($: EngineInterface, isZoomed: boolean) {
257 const wide = isZoomed && screen.columns > TRANSCRIPT_COLUMNS ? { columns: screen.columns - TRANSCRIPT_COLUMNS, rows: screen.rows } : {}
258 await $.ui.open({ id: PANE, title: 'Images', focus: true, closeOnEscape: true, ...wide })
259}
260
261async function zoom($: EngineInterface, isZoomed: boolean) {
262 if ((await read($, zoomed)) !== isZoomed) await update($, zoomed, () => isZoomed)
263 await resize($, isZoomed)
264}
265
266async function toggleZoom($: EngineInterface) {
267 await resize($, await update($, zoomed, isZoomed => !isZoomed))
268}
269
270async function view($: EngineInterface, file: string) {
271 const ran = await $.process.run(['sh', '-c', VIEW, 'sh', file]).catch(() => undefined)
272 if (ran?.exitCode !== 0) $.ui.toast('Could not open the image in a viewer.')
273}
274
275async function forget($: EngineInterface) {
276 pending.clear()
277 noted.clear()
278 stale.clear()
279 isRefreshQueued = false
280 await Promise.all([
281 update($, items, () => []),
282 update($, selected, () => ''),
283 update($, files, () => ({})),
284 update($, seen, () => ({})),
285 update($, agents, () => []),
286 update($, zoomed, () => false),
287 ])
288}
289
290export const register: Register = on => {
291 on('session.start', async ($, e, next) => {
292 await $.command.register({ name: 'image-preview', description: 'Show the images of this session in a pane' })
293
294 return next(e)
295 })
296
297 on('command.run', { command: 'image-preview' }, async $ => {
298 const count = await inOrder(() => refresh($))
299 await zoom($, false)
300
301 return {
302 text: count === 0 ? 'Images pane opened. No images in this session yet.' : `Images pane opened with ${count} image${count === 1 ? '' : 's'}.`,
303 }
304 })
305
306 on('session.append', async ($, e, next) => {
307 const kept = next(e)
308 if (await notice($, e.message.role, e.message.content, e.agentId)) {
309 void kept
310 .catch(() => undefined)
311 .then(() => refreshSoon($, e.agentId ?? MAIN))
312 .catch(() => undefined)
313 }
314
315 return kept
316 })
317
318 on('ui.message', async ($, e, next) => {
319 if (e.requestId === PANE && e.element === 'zone') await toggleZoom($)
320
321 return next(e)
322 })
323
324 on('ui.close', { id: PANE }, async ($, e, next) => {
325 if (e.origin.kind !== 'person' || !(await read($, zoomed))) return next(e)
326 await zoom($, false)
327
328 return { value: undefined }
329 })
330
331 on('session.compact', async ($, e, next) => {
332 const listed = await inOrder(() => relist($, [e.agentId ?? MAIN])).catch(() => undefined)
333 if (listed) void inOrder(() => keep($, listed.found)).catch(() => undefined)
334
335 return next(e)
336 })
337
338 on('session.end', async ($, e, next) => {
339 ended++
340 await inOrder(() => end($, e.sessionId))
341
342 return next(e)
343 })
344
345 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
346 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
347 const [kept, current, ready, isZoomed] = await Promise.all([read($, items), read($, selected), read($, files), read($, zoomed)])
348 if (e.viewport && e.surface === 'terminal') {
349 screen = { columns: e.viewport.columns + (e.props.placement === 'dock' ? e.props.bodyColumns : 0), rows: e.viewport.rows }
350 }
351 const list = [...kept].reverse()
352 const at = Math.max(0, list.findIndex(item => item.id === current))
353 const shown = list[at]
354 if (!shown) return <Text dimColor>No images in this session yet.</Text>
355
356 const first = Math.max(0, Math.min(at - Math.floor(LIST_ROWS / 2), list.length - LIST_ROWS))
357 const file = ready[shown.id]
358 const columns = e.props.bodyColumns
359 const isEnlarged = isZoomed && e.surface === 'terminal'
360 const isDrawable = file !== undefined && !('error' in file)
361 const listRows = Math.min(list.length, LIST_ROWS) + (list.length > LIST_ROWS ? 1 : 0)
362 const taken = isEnlarged ? CHROME_ROWS : listRows + FRAGMENT_ROWS + CHROME_ROWS
363 const room = Math.max(MIN_PICTURE_ROWS, e.props.scroll.bodyRows - taken)
364
365 const picture = () => {
366 if (!file) return <Text dimColor>Loading…</Text>
367 if ('error' in file) return <Text dimColor>{file.error}</Text>
368 if (e.surface !== 'terminal') return <Markdown key="preview" text={`[Open ${shown.label}](file://${encodeURI(file.file)})`} />
369 const { Image, Client } = $.ui.resolve(e)
370 const source = { file: file.file, format: 'png' as const }
371 const drawn = file.version === undefined ? source : { ...source, generation: Math.floor(file.version) }
372 const size = fit(file, columns, room)
373 return (
374 <Box flexDirection="column">
375 <Image key="preview" source={drawn} {...size} alt={shown.label} />
376 <Box position="absolute" top={0} left={0}>
377 <Client key="zone" module="./zone.tsx" props={size} width={size.columns} height={size.rows} />
378 </Box>
379 </Box>
380 )
381 }
382
383 const actions = () => (
384 <Box flexDirection="row" gap={3}>
385 {(isEnlarged || isDrawable) && (
386 <Button key="enlarge" plain hotkey="e" label={isEnlarged ? 'Back to list' : 'Enlarge'} onPress={() => void toggleZoom($)} />
387 )}
388 {file !== undefined && !('error' in file) && (
389 <Button key="view" plain hotkey="o" label="Open in viewer" onPress={() => void view($, file.file)} />
390 )}
391 </Box>
392 )
393
394 if (isEnlarged) {
395 return (
396 <Box flexDirection="column">
397 {picture()}
398 {actions()}
399 </Box>
400 )
401 }
402
403 const last = Math.min(list.length, first + LIST_ROWS)
404 const rows = (from: number, to: number) =>
405 list.slice(from, to).map((item, i) => {
406 const place = from + i + 1
407 const time = timeOf(item)
408 const text = Number.isFinite(time) ? `${item.label} ${clockTime(time)}` : item.label
409 const row = place <= 9 ? { hotkey: String(place), label: text } : { label: `${place}: ${text}` }
410 const pick = () => void inOrder(() => show($, item.id, undefined, true))
411 return <Button key={`pick-${item.id}`} plain {...row} dimColor={item.id !== current} onPress={pick} />
412 })
413
414 return (
415 <Box flexDirection="column">
416 {rows(first, at + 1)}
417 <Box marginBottom={at + 1 < last || list.length > LIST_ROWS ? 1 : 0} flexDirection="column">
418 {picture()}
419 {e.surface === 'terminal' && actions()}
420 <Text dimColor wrap="wrap">
421 {shown.fragment.slice(0, columns * FRAGMENT_ROWS)}
422 </Text>
423 </Box>
424 {rows(at + 1, last)}
425 {list.length > LIST_ROWS && <Text dimColor>{list.length} images</Text>}
426 </Box>
427 )
428 })
429}
430hooks/collect.ts 194 lines1export type Block = { type: string; [field: string]: unknown }
2export type Message = { role: string; content: readonly Block[] }
3export type Found = { id: string; label: string; fragment: string } & (
4 | { kind: 'block'; mediaType: string; data: string; readFrom?: string }
5 | { kind: 'path'; path: string; bases: readonly string[]; from: 'call' | 'text' }
6)
7
8export type Pending = Map<string, boolean>
9
10type ToolUse = { name: string; input: unknown; said: string; bases: readonly string[] }
11
12const EXTENSION = '\\.(?:png|jpe?g|gif|webp)'
13const WORD = '\\p{L}\\p{N}_@%+.\\-/~'
14const IMAGE_PATH = new RegExp(
15 `"([^"\\n]{1,512}${EXTENSION})"|'([^'\\n]{1,512}${EXTENSION})'|(?<![${WORD}\\\\])((?:[${WORD}]|\\\\ ){1,512}${EXTENSION})(?![\\p{L}\\p{N}_])`,
16 'giu',
17)
18const WHOLE_PATH = new RegExp(`^[^\\n]{1,512}${EXTENSION}$`, 'iu')
19const HARNESS_TEXT =
20 /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat)>[\s\S]*?<\/\1>|\[Image: source: [^\]\n]*\]/g
21const CHANGE_DIR = /(?:^|[;&|(\n])\s*cd\s+(?:"([^"\n]+)"|'([^'\n]+)'|((?:[^\s;&|()\\]|\\.)+))/g
22const MAX_FRAGMENT = 400
23const MAX_PATHS_PER_CALL = 12
24const MAX_BASES = 8
25const SAMPLE = 64
26const UNKNOWN_TOOL: ToolUse = { name: 'tool', input: undefined, said: '', bases: [] }
27
28const isRecord = (value: unknown): value is Record<string, unknown> =>
29 typeof value === 'object' && value !== null && !Array.isArray(value)
30
31const strings = (value: unknown): string[] =>
32 typeof value === 'string'
33 ? [value]
34 : Array.isArray(value)
35 ? value.flatMap(strings)
36 : isRecord(value)
37 ? Object.values(value).flatMap(strings)
38 : []
39
40const unique = (values: string[]) => [...new Set(values)]
41
42const squeeze = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, MAX_FRAGMENT)
43
44const hash = (text: string) => {
45 let sum = 0x811c9dc5
46 for (let i = 0; i < text.length; i++) sum = Math.imul(sum ^ text.charCodeAt(i), 0x01000193)
47 return (sum >>> 0).toString(36)
48}
49
50const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
51
52const unescaped = (text: string) => text.replace(/\\(.)/g, '$1')
53
54const toolLabel = (name: string) => {
55 const mcp = /^mcp__(.+?)__(.+)$/.exec(name)
56 return mcp ? `${mcp[2]} (${mcp[1]})` : name
57}
58
59const writesFiles = (tool: string) => tool === 'Bash' || tool.startsWith('mcp__')
60
61const innerBlocks = (block: Block) => (Array.isArray(block.content) ? (block.content as Block[]) : [])
62
63const pathsIn = (text: string): string[] =>
64 [...text.matchAll(IMAGE_PATH)].flatMap(match => {
65 const quoted = match[1] ?? match[2]
66 return quoted === undefined ? [unescaped(match[3] ?? '')] : [quoted, ...pathsIn(quoted)]
67 })
68
69const changedDirs = (command: unknown) =>
70 typeof command === 'string' ? [...command.matchAll(CHANGE_DIR)].map(match => match[1] ?? match[2] ?? unescaped(match[3] ?? '')) : []
71
72const isRooted = (path: string) => path.startsWith('/') || path.startsWith('~')
73
74const proseOf = (blocks: readonly Block[]) =>
75 blocks
76 .flatMap(block =>
77 block.type === 'text' && typeof block.text === 'string' && !block.text.startsWith('<system-reminder>')
78 ? [block.text.replace(HARNESS_TEXT, '')]
79 : [],
80 )
81 .join(' ')
82
83const resultText = (block: Block) => (typeof block.content === 'string' ? block.content : proseOf(innerBlocks(block)))
84
85export const pathId = (path: string) => `path-${hash(path)}`
86
87const candidateId = (path: string, bases: readonly string[]) => pathId([path, ...bases].join('\n'))
88
89export const imageId = (data: string) => {
90 const middle = Math.floor(data.length / 2)
91 return `${data.length.toString(36)}-${hash(data.slice(Math.max(0, middle - SAMPLE), middle + SAMPLE) + data.slice(-SAMPLE))}`
92}
93
94const base64Image = (block: Block) => {
95 const source = block.source
96 if (block.type !== 'image' || !isRecord(source) || source.type !== 'base64') return undefined
97 const { data, media_type: mediaType } = source
98 return typeof data === 'string' && typeof mediaType === 'string' ? { data, mediaType } : undefined
99}
100
101const imagesIn = (blocks: readonly Block[]) => blocks.flatMap(block => base64Image(block) ?? [])
102
103export const imagePaths = (value: unknown) => unique(strings(value).flatMap(pathsIn))
104
105const namedFiles = (tool: string, input: unknown, printed = '') => {
106 if (!writesFiles(tool)) return []
107 const whole = tool === 'Bash' ? [] : strings(input).map(text => text.trim()).filter(text => WHOLE_PATH.test(text))
108 const named = [...whole, ...imagePaths(input), ...imagePaths(printed)]
109 return unique(named.map(path => path.replace(/^\.\//, ''))).slice(0, MAX_PATHS_PER_CALL)
110}
111
112export function arrivals(role: string | undefined, blocks: readonly Block[], pending: Pending) {
113 const results = blocks.filter(block => block.type === 'tool_result')
114 const images = imagesIn([...blocks, ...results.flatMap(innerBlocks)])
115 for (const block of blocks) {
116 if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string' && writesFiles(block.name)) {
117 pending.set(block.id, namedFiles(block.name, block.input).length > 0)
118 }
119 }
120 const named = results.filter(block => {
121 const id = String(block.tool_use_id)
122 const hasNamed = pending.get(id)
123 pending.delete(id)
124 return hasNamed === true || (hasNamed === false && imagePaths(resultText(block)).length > 0)
125 })
126 const isMentioned = role === 'assistant' && imagePaths(proseOf(blocks)).length > 0
127
128 return { images, isNews: images.length > 0 || named.length > 0 || isMentioned }
129}
130
131export function collect(messages: readonly Message[]): Found[] {
132 const uses = new Map<string, ToolUse>()
133 const found = new Map<string, Found>()
134 const dirs: string[] = []
135 const add = (one: Found) => {
136 const known = found.get(one.id)
137 if (one.kind === 'path' && one.from === 'text' && known?.kind === 'path' && known.from === 'call') return
138 found.delete(one.id)
139 found.set(one.id, one)
140 }
141
142 for (const message of messages) {
143 const prose = proseOf(message.content)
144 const text = squeeze(prose)
145
146 if (message.role === 'assistant') {
147 for (const block of message.content) {
148 if (block.type !== 'tool_use' || typeof block.id !== 'string' || typeof block.name !== 'string') continue
149 for (const dir of block.name === 'Bash' && isRecord(block.input) ? changedDirs(block.input.command) : []) {
150 const before = dirs[0]
151 if (isRooted(dir) || before === undefined) dirs.unshift(dir)
152 else dirs.unshift(`${before}/${dir}`, dir)
153 }
154 dirs.length = Math.min(dirs.length, MAX_BASES)
155 uses.set(block.id, { name: block.name, input: block.input, said: text, bases: [...dirs] })
156 }
157 for (const path of imagePaths(prose).slice(0, MAX_PATHS_PER_CALL)) {
158 add({ kind: 'path', id: candidateId(path, dirs), path, bases: [...dirs], from: 'text', label: `file ${basename(path)}`, fragment: text })
159 }
160 continue
161 }
162
163 for (const block of message.content) {
164 const pasted = base64Image(block)
165 if (pasted) add({ kind: 'block', id: imageId(pasted.data), label: 'pasted', fragment: text, ...pasted })
166 if (block.type !== 'tool_result') continue
167
168 const use = uses.get(String(block.tool_use_id)) ?? UNKNOWN_TOOL
169 const images = imagesIn(innerBlocks(block))
170 const paths = images.length > 0 ? [] : namedFiles(use.name, use.input, resultText(block))
171 if (images.length === 0 && paths.length === 0) continue
172
173 const fragment = use.said || squeeze(`${use.name} ${strings(use.input).join(' ')}`)
174 const readFrom = isRecord(use.input) && typeof use.input.file_path === 'string' ? use.input.file_path : undefined
175 const label = readFrom ? `${toolLabel(use.name)} ${basename(readFrom)}` : toolLabel(use.name)
176 for (const one of images) add({ kind: 'block', id: imageId(one.data), label, fragment, ...one, ...(readFrom ? { readFrom } : {}) })
177
178 for (const path of paths) {
179 add({
180 kind: 'path',
181 id: candidateId(path, use.bases),
182 path,
183 bases: use.bases,
184 from: 'call',
185 label: `${toolLabel(use.name)} ${basename(path)}`,
186 fragment,
187 })
188 }
189 }
190 }
191
192 return [...found.values()]
193}
194hooks/picture.ts 30 lines1type Size = { width: number; height: number }
2
3const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47]
4const HEADER_BYTES = 24
5const MAX_CELLS = 255
6
7export function pngSize(headerBase64: string): Size | undefined {
8 let binary = ''
9 try {
10 binary = atob(headerBase64.trim())
11 } catch {
12 return undefined
13 }
14 const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
15 if (bytes.length < HEADER_BYTES || !PNG_SIGNATURE.every((byte, i) => bytes[i] === byte)) return undefined
16 const view = new DataView(bytes.buffer)
17 const size = { width: view.getUint32(16), height: view.getUint32(20) }
18 return size.width > 0 && size.height > 0 ? size : undefined
19}
20
21const clamp = (value: number, max: number) => Math.max(1, Math.min(Math.round(value), max))
22
23export function fit(size: Size, columns: number, rows: number) {
24 const maxColumns = clamp(columns, MAX_CELLS)
25 const maxRows = clamp(rows, MAX_CELLS)
26 const natural = (maxColumns * size.height) / size.width / 2
27 if (natural <= maxRows) return { columns: maxColumns, rows: clamp(natural, maxRows) }
28 return { columns: clamp((maxRows * 2 * size.width) / size.height, maxColumns), rows: maxRows }
29}
30hooks/zone.tsx 13 lines1import type { ClientModule } from 'claude-code'
2
3const Zone: ClientModule<{ columns: number; rows: number }> = (size, surface) => {
4 surface.onPointer(event => {
5 if (event.type === 'down' && event.button === 'left' && !event.ctrl && !event.alt && !event.shift) surface.post('toggle')
6 })
7 const { Box } = surface.elements
8
9 return <Box width={size.columns} height={size.rows} />
10}
11
12export default Zone
13types/index.d.ts 24 lines1export type ImageItem = {
2 id: string
3 label: string
4 fragment: string
5 at?: number
6 agentId?: string
7 file?: { path: string; version: number }
8}
9
10export type ImageFile = ({ file: string; width: number; height: number } | { error: string }) & { version?: number }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'image-preview': {
15 items: ImageItem[]
16 selected: string
17 files: Record<string, ImageFile>
18 seen: Record<string, number>
19 agents: string[]
20 zoomed: boolean
21 }
22 }
23}
24