SLOPSHOPPER

session-images

A pane listing the images you pasted and the image files Claude created in this session, newest first with each one's time and size; click a row to open it in…

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-images
│ ┃ Images ✕ › fix the failing auth test and add an audit log call │ ┃ Added by you │ ┃ Paste an image into the prompt to list it ⏺ Read(src/auth.ts) │ ┃ here. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Created by Claude ⎿ Added 2 lines, removed 1 line │ ┃ Image files Claude saves while working show ⏺ Bash(bun test) │ ┃ up here. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /images │ ⎿ session-images: Images pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Images
Added by you 0 Paste an image into the prompt to list it here. Created by Claude 0 Image files Claude saves while working show up here.
README

session-images

A pane that lists the images of the current session in two groups.

  • Added by you: every image you pasted into the prompt, including ones from before a compaction.
  • Created by Claude: image files a tool call wrote or changed, such as a chart a script saved or a screenshot taken with a command. Images Claude only read are left out.

Each row shows the image's time (or date, if it was another day) and its pixel size, newest first. A group shows eight rows until you ask for more. Click a row, or move to it with Tab and press Enter, to open the image in its default app.

The pane opens by itself the first time a session has an image. /images opens it again after you close it.

Opening images

On WSL, a row opens the image in its Windows default app through explorer.exe, the same as a double click. Elsewhere it uses xdg-open.

What it misses

  • Screenshots a tool returns without saving them to a file.
  • File paths that contain spaces.
  • Images Claude created earlier in a session that a compaction has removed from the conversation.

Install

/plugin install session-images --marketplace xRyul/claude-plugins-and-mods-by-xryul

Licence

MIT

Source 2 files
hooks/register.tsx 438 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ImageGroup, SessionImage } from '../types'
5
6const PANE = 'session-images'
7const TITLE = 'Images'
8const EXPLORER = '/mnt/c/Windows/explorer.exe'
9const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|bmp|svg|avif|tiff?|ico|heic)$/i
10// The engine stores each pasted image as a file and records its path in the
11// conversation as this line.
12const PASTED_SOURCE = /\[Image: source: ([^\]\n]+)\]/g
13// A tool call's text is searched up to this length, for this many paths.
14const MAX_TEXT = 200_000
15const MAX_PATHS = 100
16// Headers are read from files up to this size, in their first 256 KiB.
17const MAX_HEADER_FILE = 32 * 1024 * 1024
18const HEADER_BASE64 = 349_524
19// Rows a group shows before "Show more".
20const LIMIT = 8
21const OPEN = 'Open'
22const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
23
24// Change the tag when SessionImage's fields change: it declines what an
25// earlier version stored, which outlives a reload.
26const images = atom({ plugin: 'session-images', key: 'images' } as const, [], {
27  shape: 'images-2',
28})
29const hasAutoOpened = atom(
30  { plugin: 'session-images', key: 'hasAutoOpened' } as const,
31  false,
32)
33const showAll = atom({ plugin: 'session-images', key: 'showAll' } as const, {
34  user: false,
35  claude: false,
36})
37
38type Found = { path: string; source: ImageGroup }
39type Block = { type: string; [field: string]: unknown }
40
41function basename(path: string) {
42  return path.slice(path.lastIndexOf('/') + 1)
43}
44
45function dirname(path: string) {
46  return path.slice(0, path.lastIndexOf('/')) || '/'
47}
48
49function textsOf(blocks: readonly Block[]) {
50  return blocks.flatMap(block =>
51    block.type === 'text' && typeof block.text === 'string' ? [block.text] : [],
52  )
53}
54
55// Every string inside a tool's input or result, skipping inline media.
56function stringsIn(value: unknown, out: string[] = []) {
57  if (typeof value === 'string') {
58    out.push(value)
59  } else if (Array.isArray(value)) {
60    value.forEach(item => stringsIn(item, out))
61  } else if (value !== null && typeof value === 'object') {
62    const { type } = value as { type?: unknown }
63    if (type !== 'image' && type !== 'document') {
64      Object.values(value).forEach(item => stringsIn(item, out))
65    }
66  }
67
68  return out
69}
70
71function imageTokens(texts: readonly string[]) {
72  const tokens = new Set<string>()
73  for (const text of texts) {
74    for (const raw of text.slice(0, MAX_TEXT).split(/[\s"'`<>()[\]{}|;,=]+/)) {
75      const token = raw.replace(/[.:!?]+$/, '')
76      if (IMAGE_EXTENSION.test(token) && !token.includes('://')) {
77        tokens.add(token)
78      }
79    }
80  }
81
82  return [...tokens].slice(0, MAX_PATHS)
83}
84
85// The pixel size a PNG, GIF or JPEG header states.
86function pixelSize(bytes: Uint8Array) {
87  const at = (i: number) => bytes[i] ?? 0
88  const u16 = (i: number) => (at(i) << 8) | at(i + 1)
89  const u32 = (i: number) => ((u16(i) << 16) | u16(i + 2)) >>> 0
90
91  if (at(0) === 0x89 && at(1) === 0x50) return { width: u32(16), height: u32(20) }
92  if (at(0) === 0x47 && at(1) === 0x49) {
93    return { width: at(6) | (at(7) << 8), height: at(8) | (at(9) << 8) }
94  }
95  if (at(0) === 0xff && at(1) === 0xd8) {
96    // Walk the segments to the frame header (SOF0 to SOF15, less DHT, JPG, DAC).
97    for (let i = 2; i + 9 < bytes.length && at(i) === 0xff; i += 2 + u16(i + 2)) {
98      const marker = at(i + 1)
99      if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
100        return { width: u16(i + 7), height: u16(i + 5) }
101      }
102    }
103  }
104
105  return {}
106}
107
108async function describe($: EngineInterface, found: Found) {
109  const stat = await $.fs.stat(found.path).catch(() => undefined)
110  if (stat?.kind !== 'file') return undefined
111
112  let size = {}
113  if (stat.size <= MAX_HEADER_FILE) {
114    const bytes = await $.fs.read(found.path, { as: 'bytes' }).catch(() => undefined)
115    if (bytes) {
116      const head = atob(bytes.base64.slice(0, HEADER_BASE64))
117      size = pixelSize(Uint8Array.from(head, char => char.charCodeAt(0)))
118    }
119  }
120
121  return { ...found, modifiedAt: stat.mtimeMs, ...size }
122}
123
124// The pasted images the texts name, plus every image in the folders they sit
125// in, so images whose lines a compaction dropped are still found.
126async function findPasted($: EngineInterface, texts: readonly string[]) {
127  const paths = texts.flatMap(text =>
128    [...text.matchAll(PASTED_SOURCE)].flatMap(match =>
129      match[1] ? [match[1].trim()] : [],
130    ),
131  )
132  const folders = [...new Set(paths.map(dirname))]
133  const listed = await Promise.all(
134    folders.map(async folder => {
135      const entries = await $.fs.list(folder).catch(() => [])
136
137      return entries
138        .filter(entry => entry.kind === 'file' && IMAGE_EXTENSION.test(entry.name))
139        .map(entry => `${folder}/${entry.name}`)
140    }),
141  )
142
143  return [...paths, ...listed.flat()]
144}
145
146// The image files the texts name that were written at or after `since`.
147async function findCreated(
148  $: EngineInterface,
149  texts: readonly string[],
150  since: number,
151) {
152  const tokens = imageTokens(texts)
153  if (tokens.length === 0) return []
154
155  const home = (await $.env.get('HOME')) ?? ''
156  const cwd = await $.session.cwd()
157  const found: string[] = []
158  for (const token of tokens) {
159    const path = token.startsWith('~/')
160      ? home + token.slice(1)
161      : token.startsWith('/')
162        ? token
163        : `${cwd}/${token}`
164    const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
165    if (stat?.kind === 'file' && stat.mtimeMs >= since) {
166      found.push(stat.realPath ?? path)
167    }
168  }
169
170  return found
171}
172
173async function addImages($: EngineInterface, found: readonly Found[]) {
174  const known = new Set((await read($, images)).map(image => image.path))
175  const fresh = found.filter(image => {
176    if (known.has(image.path)) return false
177    known.add(image.path)
178
179    return true
180  })
181  if (fresh.length === 0) return
182
183  const described = await Promise.all(fresh.map(image => describe($, image)))
184  const added = described.filter(image => image !== undefined)
185  await update($, images, list => {
186    const stored = new Set(list.map(image => image.path))
187
188    return [...list, ...added.filter(image => !stored.has(image.path))]
189  })
190
191  if (!(await read($, hasAutoOpened))) {
192    await update($, hasAutoOpened, () => true)
193    void $.ui.open({ id: PANE, title: TITLE })
194  }
195}
196
197// What the conversation already holds, for a session the mod joins midway.
198async function backfill($: EngineInterface) {
199  const messages = await $.session.messages({ as: 'api' })
200  if (!Array.isArray(messages)) return
201
202  const userTexts: string[] = []
203  const toolTexts: string[] = []
204  for (const message of messages) {
205    if (message.role === 'user') userTexts.push(...textsOf(message.content))
206    for (const block of message.content) {
207      if (block.type === 'tool_use') stringsIn(block.input, toolTexts)
208      if (block.type === 'tool_result') stringsIn(block.content, toolTexts)
209    }
210  }
211
212  const { startedAt } = await $.session.usage()
213  const pasted = await findPasted($, userTexts)
214  const created = await findCreated($, toolTexts, startedAt)
215  await addImages($, [
216    ...pasted.map(path => ({ path, source: 'user' as const })),
217    ...created.map(path => ({ path, source: 'claude' as const })),
218  ])
219}
220
221async function openImage($: EngineInterface, path: string) {
222  const name = basename(path)
223  try {
224    if (!(await $.fs.exists(path))) {
225      $.ui.toast(`${name} no longer exists`)
226
227      return
228    }
229    if (await $.fs.exists(EXPLORER)) {
230      const { stdout } = await $.process.run(['/usr/bin/wslpath', '-w', path])
231      // explorer.exe exits 1 even when it opened the file.
232      await $.process.run([EXPLORER, stdout.trim()], {
233        cwd: '/mnt/c',
234        timeoutMs: 15_000,
235      })
236    } else {
237      await $.process.run(['xdg-open', path], { timeoutMs: 15_000 })
238    }
239    $.ui.toast(`Opened ${name}`)
240  } catch (error) {
241    const reason = error instanceof Error ? error.message : String(error)
242    $.ui.toast(`Could not open ${name}: ${reason}`)
243  }
244}
245
246function pastedNumber(image: SessionImage) {
247  return Number(/^(\d+)\./.exec(basename(image.path))?.[1] ?? 0)
248}
249
250// "14:05" for today, "25 Oct" for an earlier day.
251function shortTime(ms: number, now: number) {
252  const date = new Date(ms)
253  if (date.toDateString() !== new Date(now).toDateString()) {
254    return `${date.getDate()} ${MONTHS[date.getMonth()]}`
255  }
256
257  return [date.getHours(), date.getMinutes()]
258    .map(part => String(part).padStart(2, '0'))
259    .join(':')
260}
261
262function truncateMiddle(text: string, width: number) {
263  if (text.length <= width) return text
264  const head = Math.ceil((width - 1) / 2)
265
266  return `${text.slice(0, head)}…${text.slice(text.length - (width - 1 - head))}`
267}
268
269// A row's name and details, cut to `room` columns: the pixel size goes first,
270// then the time, then the name loses its middle.
271function fitRow(name: string, details: readonly string[], room: number) {
272  const kept = [...details]
273  const length = () => name.length + (kept.length ? 2 + kept.join('  ').length : 0)
274  while (kept.length > 0 && length() > room) kept.pop()
275
276  return {
277    name: truncateMiddle(name, room),
278    detail: kept.length ? `  ${kept.join('  ')}` : '',
279  }
280}
281
282export const register: Register = on => {
283  on('session.start', async ($, e, next) => {
284    await $.command.register({
285      name: 'images',
286      description: 'Show the images of this session in a pane',
287    })
288    await backfill($)
289
290    return next(e)
291  })
292
293  on('command.run', { command: 'images' }, async $ => {
294    await $.ui.open({ id: PANE, title: TITLE })
295
296    return { text: 'Images pane opened.' }
297  })
298
299  on('session.append', async ($, e, next) => {
300    const stored = await next(e)
301    if (e.message.type === 'user') {
302      const texts = textsOf(e.message.content)
303      if (texts.some(text => text.includes('[Image: source: '))) {
304        const pasted = await findPasted($, texts)
305        await addImages(
306          $,
307          pasted.map(path => ({ path, source: 'user' as const })),
308        )
309      }
310    }
311
312    return stored
313  }).catch(($, e, next) => next(e))
314
315  on('tool.call', async ($, e, next) => {
316    // A second's slack for file systems that round modification times.
317    const since = (await $.clock.now()) - 1000
318    const ran = await next(e)
319    if (ran.deny === undefined) {
320      const created = await findCreated($, [...stringsIn(e), ran.text ?? ''], since)
321      await addImages(
322        $,
323        created.map(path => ({ path, source: 'claude' as const })),
324      )
325    }
326
327    return ran
328  }).catch(($, e, next) => next(e))
329
330  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
331    const { Box, Text, Button } = $.ui.resolve(e)
332    const list = await read($, images)
333    const expanded = await read($, showAll)
334    const now = await $.clock.now()
335    // One column spare, so a full row never wraps at the frame's edge.
336    const width = Math.max(20, e.props.bodyColumns - 1)
337
338    const pasted = list
339      .filter(image => image.source === 'user')
340      .sort((a, b) => pastedNumber(b) - pastedNumber(a) || b.modifiedAt - a.modifiedAt)
341    const created = list
342      .filter(image => image.source === 'claude')
343      .sort((a, b) => b.modifiedAt - a.modifiedAt)
344    const createdNames = created.map(image => basename(image.path))
345
346    const labelOf = (image: SessionImage) => {
347      if (image.source === 'user') {
348        const number = pastedNumber(image)
349
350        return number ? `Image #${number}` : basename(image.path)
351      }
352      const name = basename(image.path)
353      const isShared = createdNames.filter(other => other === name).length > 1
354
355      return isShared ? `${basename(dirname(image.path))}/${name}` : name
356    }
357
358    const row = (image: SessionImage) => {
359      const details = [shortTime(image.modifiedAt, now)]
360      if (image.width && image.height) details.push(`${image.width}×${image.height}`)
361      const room = width - OPEN.length - 1
362      const { name, detail } = fitRow(labelOf(image), details, room)
363      const gap = ' '.repeat(Math.max(1, width - OPEN.length - name.length - detail.length))
364
365      // The keyed Box is what the pointer hovers, lighting the whole row.
366      return (
367        <Box key={`row:${image.path}`}>
368          <Button
369            key={`open:${image.path}`}
370            plain
371            hover={{ inverse: true }}
372            onPress={() => openImage($, image.path)}
373          >
374            {name}
375            <Text dimColor>{detail}</Text>
376            {gap}
377            <Text color="claude">{OPEN}</Text>
378          </Button>
379        </Box>
380      )
381    }
382
383    const group = (
384      key: ImageGroup,
385      title: string,
386      items: readonly SessionImage[],
387      empty: string,
388    ) => {
389      const isOpen = expanded[key]
390      const shown = isOpen ? items : items.slice(0, LIMIT)
391
392      return (
393        <Box flexDirection="column">
394          <Box flexDirection="row" justifyContent="space-between" width={width}>
395            <Text bold>{title}</Text>
396            <Text dimColor>{String(items.length)}</Text>
397          </Box>
398          {items.length === 0 && <Text dimColor>{empty}</Text>}
399          {shown.map(row)}
400          {items.length > LIMIT && (
401            <Box key={`more-row:${key}`}>
402              <Button
403                key={`more:${key}`}
404                plain
405                hover={{ underline: true }}
406                onPress={() =>
407                  update($, showAll, state => ({ ...state, [key]: !state[key] }))
408                }
409              >
410                <Text dimColor>
411                  {isOpen ? 'Show fewer' : `Show ${items.length - LIMIT} more`}
412                </Text>
413              </Button>
414            </Box>
415          )}
416        </Box>
417      )
418    }
419
420    return (
421      <Box flexDirection="column" rowGap={1}>
422        {group(
423          'user',
424          'Added by you',
425          pasted,
426          'Paste an image into the prompt to list it here.',
427        )}
428        {group(
429          'claude',
430          'Created by Claude',
431          created,
432          'Image files Claude saves while working show up here.',
433        )}
434      </Box>
435    )
436  })
437}
438
types/index.d.ts 22 lines
1export type SessionImage = {
2  path: string
3  source: 'user' | 'claude'
4  /** When the file was last written, milliseconds since the epoch. */
5  modifiedAt: number
6  /** Pixel size, read from a PNG, GIF or JPEG header. */
7  width?: number
8  height?: number
9}
10
11export type ImageGroup = SessionImage['source']
12
13declare module 'claude-code' {
14  interface PluginState {
15    'session-images': {
16      images: Shaped<SessionImage[]>
17      hasAutoOpened: boolean
18      showAll: Record<ImageGroup, boolean>
19    }
20  }
21}
22