Draws a thumbnail under a Claude reply that names a screenshot path, with a click to open it

A Claude Code mod for the reply that says Screenshot: /var/folders/…/screenshot-1791286907506-0.jpg and leaves you to go find the file. It draws a thumbnail of that image right under the reply, with ↗ open to see it full size.
Inside Claude Code, run:
/plugin marketplace add mikhin/claude-plugins
/plugin install shot-preview@mikhin
/reload-plugins
sips, which only macOS has. On Linux and Windows a PNG gets a thumbnail and a JPEG gets only ↗ open.● Screenshot: /var/folders/…/screenshot-1791286907506-0.jpg
(thumbnail)
↗ open
session.append hook reads each reply of the main conversation as it is stored and finds paths ending in .png, .jpg or .jpeg: absolute, ~/-relative or C:\-style. A path in backticks is taken whole, so ` ~/Scans/passport (1).jpg ` works; a bare one ends at the first space.$TMPDIR/claude-shots/, named by a hash of its path and modification time (macOS).ui.render hook on the reply draws the reply as Claude Code does, then the thumbnails under it with Claude Code's Image element.Only replies written in the current session get a thumbnail: the mod remembers previews for the session alone, so a resumed conversation shows its old paths as text. Subagent replies are skipped.
The mod runs when Claude Code stores a reply and when you press ↗ open.
uname, once, to tell macOS from Linuxmkdir -p $TMPDIR/claude-shots makes the folder previews go to (macOS)sips -s format png -Z 800 <image> --out <preview>.png turns a JPEG into a PNG preview (macOS)sips -g pixelWidth -g pixelHeight <preview>.png reads the preview's size (macOS)open <image> on macOS, xdg-open <image> on Linux, cmd /c start "" <image> on WindowsOS, TMPDIR, HOME and USERPROFILE environment variables, checks that each path named in a reply exists and reads its modification time, and writes <hash>.png previews to $TMPDIR/claude-shots/. It never deletes them; macOS clears that folder.MIT
hooks/register.tsx 122 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Platform, Preview } from '../types'
5import { imagePaths } from './paths'
6
7const THUMB_COLUMNS = 36
8const THUMB_ROWS = 10
9
10const previews = atom({ plugin: 'shot-preview', key: 'previews' } as const, {})
11const platform = atom({ plugin: 'shot-preview', key: 'platform' } as const, null)
12
13type $ = EngineInterface
14
15const resolvePath = async ($: $, path: string): Promise<string> => {
16 if (!path.startsWith('~/')) return path
17 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
18 return `${home.replace(/[/\\]$/, '')}${path.slice(1)}`
19}
20
21const hash = (text: string): string => {
22 let h = 0x811c9dc5
23 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
24 return (h >>> 0).toString(16)
25}
26
27const pixels = (out: string, name: string): number => Number(new RegExp(`${name}: (\\d+)`).exec(out)?.[1] ?? 0)
28
29const detectPlatform = async ($: $): Promise<Platform> => {
30 const known = await read($, platform)
31 if (known) return known
32 const found: Platform =
33 (await $.env.get('OS')) === 'Windows_NT'
34 ? 'windows'
35 : (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
36 ? 'mac'
37 : 'linux'
38 await update($, platform, () => found)
39 return found
40}
41
42const OPEN: Record<Platform, (path: string) => string[]> = {
43 mac: path => ['open', path],
44 linux: path => ['xdg-open', path],
45 windows: path => ['cmd', '/c', 'start', '""', path],
46}
47
48const toPreview = async ($: $, file: string): Promise<Preview> => {
49 const isPng = /\.png$/i.test(file)
50 if ((await detectPlatform($)) !== 'mac') return { file, png: isPng ? file : null, width: 0, height: 0 }
51 let png = file
52 if (!isPng) {
53 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp/').replace(/\/?$/, '/')
54 const cache = `${tmp}claude-shots`
55 const { mtimeMs } = await $.fs.stat(file)
56 png = `${cache}/${hash(`${file}:${mtimeMs}`)}.png`
57 if (!(await $.fs.exists(png))) {
58 await $.process.run(['mkdir', '-p', cache])
59 await $.process.run(['sips', '-s', 'format', 'png', '-Z', '800', file, '--out', png])
60 }
61 }
62 const { stdout } = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png])
63 return { file, png, width: pixels(stdout, 'pixelWidth'), height: pixels(stdout, 'pixelHeight') }
64}
65
66const fit = (width: number, height: number) => {
67 const [w, h] = width > 0 && height > 0 ? [width, height] : [16, 9]
68 const wide = { columns: THUMB_COLUMNS, rows: Math.round((THUMB_COLUMNS * h) / w / 2) }
69 const box = wide.rows <= THUMB_ROWS ? wide : { columns: Math.round((THUMB_ROWS * 2 * w) / h), rows: THUMB_ROWS }
70 return { columns: Math.max(1, box.columns), rows: Math.max(1, box.rows) }
71}
72
73export const register: Register = on => {
74 on('session.append', async ($, e, next) => {
75 const stored = await next(e)
76 if (e.door !== 'response' || e.agentId !== undefined) return stored
77 const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
78 const known = await read($, previews)
79 const fresh: [string, Preview][] = []
80 for (const path of imagePaths(text)) {
81 if (known[path]) continue
82 const file = await resolvePath($, path)
83 if (await $.fs.exists(file)) fresh.push([path, await toPreview($, file)])
84 }
85 if (fresh.length > 0) await update($, previews, all => ({ ...all, ...Object.fromEntries(fresh) }))
86 return stored
87 })
88
89 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
90 if (e.surface !== 'terminal') return next(e)
91 const known = await read($, previews)
92 const shots = imagePaths(e.props.text).flatMap(path => (known[path] ? [{ path, ...known[path] }] : []))
93 if (shots.length === 0) return next(e)
94
95 const { Box, Button, Image } = $.ui.resolve(e)
96
97 return (
98 <Box flexDirection="column">
99 {await next(e)}
100 <Box gap={2} flexWrap="wrap" marginLeft={2}>
101 {shots.map(shot => {
102 const room = fit(shot.width, shot.height)
103 return (
104 <Box key={shot.path} flexDirection="column">
105 {shot.png && <Image key={shot.path} source={{ file: shot.png, format: 'png' }} columns={room.columns} rows={room.rows} alt={shot.path} />}
106 <Button
107 key={`open:${shot.path}`}
108 plain
109 dimColor
110 onPress={async () => void (await $.process.run(OPEN[await detectPlatform($)](shot.file)))}
111 >
112 ↗ open
113 </Button>
114 </Box>
115 )
116 })}
117 </Box>
118 </Box>
119 )
120 })
121}
122hooks/paths.ts 13 lines1const IMAGE = /\.(?:png|jpe?g)$/i
2const PATH_START = /^(?:~\/|\/|[A-Za-z]:\\)/
3const CODE_SPAN = /`([^`\n]+)`/g
4const BARE_PATH = /(?:~\/|[A-Za-z]:\\|\/)[^\s`'"()<>[\]]+\.(?:png|jpe?g)/gi
5
6export const imagePaths = (text: string): string[] => {
7 const quoted = [...text.matchAll(CODE_SPAN)]
8 .map(match => (match[1] ?? '').trim())
9 .filter(span => PATH_START.test(span) && IMAGE.test(span))
10 const bare = text.replace(CODE_SPAN, ' ').match(BARE_PATH) ?? []
11 return [...new Set([...quoted, ...bare])]
12}
13types/index.d.ts 10 lines1export type Preview = { file: string; png: string | null; width: number; height: number }
2
3export type Platform = 'mac' | 'linux' | 'windows'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'shot-preview': { previews: Record<string, Preview>; platform: Platform | null }
8 }
9}
10