SLOPSHOPPER

image-preview

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

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · image-preview
│ ┃ images ✕ › fix the failing auth test and add an audit log call │ ┃ No images in this session yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /image-preview │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · images
No images in this session yet.
README

image-preview

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

  • Newest first. 1–9, or the arrows and Enter, pick an image; Esc closes the pane.
  • New images show up while the pane is open.
  • Click the picture, or press 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.
  • Pictures draw in terminals with the kitty graphics protocol (Ghostty, kitty). Other terminals show the image's name in its place, and the desktop app links to the image file.
  • Needs macOS or Linux. Images that are not PNG are converted with sips (macOS) or ImageMagick.

Install

/plugin marketplace add scoobynko/claude-code-mods
/plugin install image-preview@scoobynko-mods

How it works

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.

Test

claude plugin test plugins/image-preview
Source 5 files
hooks/register.tsx 430 lines
1import { 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}
430
hooks/collect.ts 194 lines
1export 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}
194
hooks/picture.ts 30 lines
1type 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}
30
hooks/zone.tsx 13 lines
1import 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
13
types/index.d.ts 24 lines
1export 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