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…

A pane that lists the images of the current session in two groups.
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.
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.
/plugin install session-images --marketplace xRyul/claude-plugins-and-mods-by-xryul
MIT
hooks/register.tsx 438 lines1import { 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}
438types/index.d.ts 22 lines1export 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