Thumbnails of the images you paste, above the prompt; click the magnifier to enlarge one in a pane.

A mod (function hooks that run inside Claude Code) that shows the images you paste while they are still in the prompt box.
Ctrl+V): a thumbnail of it appears above the prompt, one per [Image #N] in the box.🔍 #N button under a thumbnail: the picture opens enlarged in a pane, with its pixel size.The thumbnail itself is not clickable (an Image element takes no onPress), hence the button under it.
sips, and the clipboard fallback uses osascript./plugin install image-peek --marketplace shdennlin/agent-plugins
<tmp>/claude-<uid>/<project>/<session>/images/N.png as soon as it is pasted. The mod reads the file from there, the terminal decodes it (Image with a file source), nothing is copied.prompt.edit, so the mod looks at the box every 300 ms with $.prompt.read() and follows the [Image #N] tags.$DARWIN_USER_TEMP_DIR/image-peek/<session>/N.png (macOS's per-user temp folder; each folder is created mode 700 and checked to be a real directory of yours before anything is written) and shows that.await next(e)), so it sits beside other mods' bands such as cut-input instead of replacing them.claude plugin validate plugins/image-peek
claude plugin test plugins/image-peek
claude --plugin-dir ./plugins/image-peek # lays .claude-plugin/types/ for tsc -p plugins/image-peek
/private/tmp, sips, osascript).hooks/register.tsx 233 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4const PANE = 'image-peek'
5const THUMB_COLS = 16
6const THUMB_ROWS = 6
7const RETRIES = 12
8
9const tags = atom({ plugin: 'image-peek', key: 'tags' } as const, [])
10const selected = atom({ plugin: 'image-peek', key: 'selected' } as const, null)
11
12let imagesDir: string | null = null
13let cacheBase: string | null = null
14const sizes = new Map<string, { w: number; h: number }>()
15const snapped = new Set<number>()
16const missingFor = new Map<number, number>() // polls a tag has gone without its file
17let retries = 0
18let isWaiting = false
19
20// The folder Claude Code stores pasted images in: <tmp>/claude-<uid>/<project>/<session>/images
21async function dirOf($: Engine): Promise<string | null> {
22 if (imagesDir !== null) return imagesDir
23 const uid = (await $.process.run(['id', '-u'])).stdout.trim()
24 const session = await $.session.id()
25 const root = `/private/tmp/claude-${uid}`
26 for (const project of await $.fs.list(root)) {
27 const dir = `${root}/${project.name}/${session}/images`
28 if (await $.fs.exists(dir)) {
29 imagesDir = dir
30 return dir
31 }
32 }
33 return null
34}
35
36// The copy lives under macOS's per-user temp folder (mode 700), never the shared /tmp, so no
37// other user can plant a link at the path or read the picture.
38async function cacheRoot($: Engine): Promise<string> {
39 if (cacheBase === null) {
40 const base = (await $.process.run(['getconf', 'DARWIN_USER_TEMP_DIR'])).stdout.trim().replace(/\/$/, '')
41 cacheBase = `${base}/image-peek`
42 }
43 return cacheBase
44}
45
46async function cacheOf($: Engine, n: number): Promise<string> {
47 const session = await $.session.id()
48 return `${await cacheRoot($)}/${session}/${n}.png`
49}
50
51// Make `dir` ourselves (no -p) and use it only if it is a real directory, ours, mode 700; stat
52// does not follow a link, so a planted link shows as something else and is refused.
53async function isPrivateDir($: Engine, dir: string): Promise<boolean> {
54 const uid = (await $.process.run(['id', '-u'])).stdout.trim()
55 await $.process.run(['mkdir', '-m', '700', dir])
56 const seen = (await $.process.run(['stat', '-f', '%u %Lp %HT', dir])).stdout.trim()
57 return seen === `${uid} 700 Directory`
58}
59
60async function fileOf($: Engine, n: number): Promise<string | null> {
61 const dir = await dirOf($)
62 if (dir !== null && (await $.fs.exists(`${dir}/${n}.png`))) return `${dir}/${n}.png`
63 const cached = await cacheOf($, n)
64 return (await $.fs.exists(cached)) ? cached : null
65}
66
67// Fallback for a paste whose file Claude Code has not written: copy the clipboard's PNG.
68async function snapshot($: Engine, n: number) {
69 const file = await cacheOf($, n)
70 const session = file.slice(0, file.lastIndexOf('/'))
71 if (!(await isPrivateDir($, await cacheRoot($))) || !(await isPrivateDir($, session))) return
72 await $.process.run([
73 'osascript',
74 '-e', 'set d to (the clipboard as «class PNGf»)',
75 '-e', `set f to open for access POSIX file "${file}" with write permission`,
76 '-e', 'set eof f to 0',
77 '-e', 'write d to f',
78 '-e', 'close access f',
79 ])
80}
81
82async function sizeOf($: Engine, file: string) {
83 const known = sizes.get(file)
84 if (known) return known
85 const out = (await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])).stdout
86 const w = Number(/pixelWidth: (\d+)/.exec(out)?.[1] ?? 0)
87 const h = Number(/pixelHeight: (\d+)/.exec(out)?.[1] ?? 0)
88 const size = w > 0 && h > 0 ? { w, h } : { w: 4, h: 3 }
89 sizes.set(file, size)
90 return size
91}
92
93// Fit a picture into maxCols x maxRows cells, keeping its aspect (a cell is about 1:2).
94function fit(size: { w: number; h: number }, maxCols: number, maxRows: number) {
95 const ratio = size.w / size.h / 0.5
96 let columns = maxCols
97 let rows = Math.round(columns / ratio)
98 if (rows > maxRows) {
99 rows = maxRows
100 columns = Math.round(rows * ratio)
101 }
102 return { columns: Math.max(1, Math.min(255, columns)), rows: Math.max(1, Math.min(255, rows)) }
103}
104
105const sameTags = (a: number[], b: number[]) => a.length === b.length && a.every((n, i) => n === b[i])
106
107// Look at the box on a timer (a paste raises no prompt.edit). The band is redrawn only when the
108// tags change, never on a plain edit.
109async function syncTags($: Engine) {
110 const { text } = await $.prompt.read()
111 const found = [...text.matchAll(/\[Image #(\d+)\]/g)].map(m => Number(m[1]))
112 const old = await read($, tags)
113
114 for (const n of found) {
115 if (snapped.has(n)) continue
116 if ((await fileOf($, n)) !== null) {
117 missingFor.delete(n)
118 continue
119 }
120 const polls = (missingFor.get(n) ?? 0) + 1
121 missingFor.set(n, polls)
122 if (polls >= 4) {
123 snapped.add(n)
124 await snapshot($, n)
125 }
126 }
127
128 if (!sameTags(old, found)) {
129 await update($, tags, () => found)
130 }
131
132 // A file that was missing at the last draw: redraw once it is there.
133 if (isWaiting && retries < RETRIES) {
134 retries += 1
135 const files = await Promise.all(found.map(n => fileOf($, n)))
136 if (files.every(f => f !== null)) {
137 isWaiting = false
138 $.ui.invalidate('ui.render')
139 }
140 }
141}
142
143export const register: Register = on => {
144 on('session.start', async ($, e, next) => {
145 $.clock.every(300, () => {
146 void syncTags($)
147 })
148
149 return next(e)
150 })
151
152 on('prompt.submit', async ($, e, next) => {
153 update($, tags, () => []).catch(() => {})
154 update($, selected, () => null).catch(() => {})
155 $.ui.close({ id: PANE }).catch(() => {})
156 retries = 0
157
158 return next(e)
159 })
160
161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162 const list = await read($, tags)
163 if (e.surface !== 'terminal' || e.props.hasSurvey || list.length === 0) {
164 return next(e)
165 }
166
167 const { Box, Text, Image, Button } = $.ui.resolve(e)
168 const below = await next(e)
169 const items = await Promise.all(
170 list.map(async n => {
171 const file = await fileOf($, n)
172 return { n, file, size: file ? await sizeOf($, file) : null }
173 }),
174 )
175 isWaiting = items.some(i => i.file === null)
176
177 const room = Math.max(2, Math.min(THUMB_ROWS, e.props.maxRows - 5))
178 const cols = Math.max(6, Math.min(THUMB_COLS, Math.floor(e.props.bodyColumns / items.length) - 4))
179
180 return (
181 <Box flexDirection="column">
182 {below}
183 <Box flexDirection="row">
184 {items.map(({ n, file, size }) => (
185 <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
186 {file && size ? (
187 <Image key={`img-${n}`} source={{ file, format: 'png' }} {...fit(size, cols, room)} alt={`Image #${n}`} />
188 ) : (
189 <Text dimColor>loading…</Text>
190 )}
191 <Text dimColor>#{n}</Text>
192 </Box>
193 ))}
194 </Box>
195 <Box flexDirection="row">
196 {items.map(({ n }) => (
197 <Button
198 key={`open-${n}`}
199 label={`🔍 #${n}`}
200 onPress={async () => {
201 await update($, selected, () => n)
202 await $.ui.open({ id: PANE, title: `Image #${n}`, focus: true })
203 }}
204 />
205 ))}
206 </Box>
207 </Box>
208 )
209 })
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const n = await read($, selected)
213 const { Box, Text, Image } = $.ui.resolve(e)
214 const file = n === null ? null : await fileOf($, n)
215
216 if (n === null || file === null) {
217 return <Text dimColor>No image.</Text>
218 }
219
220 const size = await sizeOf($, file)
221 const box = fit(size, Math.max(8, (e.viewport?.columns ?? 80) - 6), Math.max(4, (e.viewport?.rows ?? 24) - 6))
222
223 return (
224 <Box flexDirection="column">
225 <Image key={`big-${n}`} source={{ file, format: 'png' }} {...box} alt={`Image #${n}`} />
226 <Text dimColor>
227 #{n} · {size.w}×{size.h}px
228 </Text>
229 </Box>
230 )
231 })
232}
233types/index.d.ts 8 lines1export type PeekTag = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'image-peek': { tags: PeekTag[]; selected: PeekTag | null }
6 }
7}
8