Thumbnails above the prompt for every image you paste

A Claude Code mod that shows the images you paste as thumbnails above the prompt, instead of bare [Image #1] tags.

sips, so those previews are macOS only.The Desktop app already previews pasted images, so peek draws nothing there.
CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 in your environment, and set -g allow-passthrough on for tmux./plugin marketplace add PickleBoxer/claude-plugins
/plugin install peek@pickleboxer
<tmp>/claude-<uid>/<project>/<session>/images/<n>.<ext> and puts an [Image #n] tag in the prompt. peek finds the folder by the session id and draws each file with Claude Code's Image element. The terminal reads the file itself, so the image data never passes through the mod.The cache path is internal to Claude Code, so if a release moves it the tiles say "no preview" until peek is updated.
claude --plugin-dir . # loads the mod and reloads it on save
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Inspired by claude-image-view by Jarrod Watts, which first found that a paste raises no edit event and that the draft has to be polled instead.
MIT
hooks/register.tsx 184 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Preview, Size } from '../types'
5import { fitRow, isSame, placeholders, pngSize } from './images'
6
7// Pasting an image raises no prompt.edit (the placeholder only lands with the next key),
8// so the draft is polled instead
9const POLL_MS = 200
10const EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
11
12const previewsAtom = atom({ plugin: 'peek', key: 'previews' } as const, [] as Preview[])
13
14// Only cached once found: the folder appears with the session's first paste
15let imagesDir: string | null = null
16let isPolling = false
17const sizes = new Map<string, Size | null>()
18
19// Claude Code writes each pasted image to <tmp>/claude-<uid>/<project-slug>/<sessionId>/images/N.<ext>,
20// keeping its original format.
21// The path is internal and may move between releases; when it does, tiles say "no preview".
22async function findImagesDir($: EngineInterface): Promise<string | null> {
23 const sessionId = await $.session.id()
24 const custom = await $.env.get('CLAUDE_CODE_TMPDIR')
25 const roots = custom
26 ? [custom]
27 : (await $.fs.list('/tmp').catch(() => []))
28 .filter(entry => entry.name.startsWith('claude-'))
29 .map(entry => `/tmp/${entry.name}`)
30
31 for (const root of roots) {
32 for (const project of await $.fs.list(root).catch(() => [])) {
33 const dir = `${root}/${project.name}/${sessionId}/images`
34
35 if (project.kind === 'dir' && (await $.fs.exists(dir))) {
36 return dir
37 }
38 }
39 }
40
41 return null
42}
43
44async function findSource($: EngineInterface, n: number): Promise<string | null> {
45 for (const extension of EXTENSIONS) {
46 const file = `${imagesDir}/${n}.${extension}`
47
48 if (await $.fs.exists(file)) {
49 return file
50 }
51 }
52
53 return null
54}
55
56// Image draws PNG only, so other formats get a PNG copy once, through macOS sips.
57// The copy stays inside Claude Code's cache, which only this user can enter.
58async function toPng($: EngineInterface, source: string, n: number): Promise<string | null> {
59 if (source.endsWith('.png')) {
60 return source
61 }
62
63 const dir = `${imagesDir}/peek`
64 const file = `${dir}/${n}.png`
65
66 if (await $.fs.exists(file)) {
67 return file
68 }
69
70 const converted = await $.process.run(['mkdir', '-p', dir])
71 .then(() => $.process.run(['sips', '-s', 'format', 'png', '-Z', '512', source, '--out', file]))
72 .catch(() => null)
73
74 return converted?.exitCode === 0 ? file : null
75}
76
77async function resolvePreview($: EngineInterface, n: number): Promise<Preview> {
78 imagesDir ??= await findImagesDir($)
79
80 const source = imagesDir ? await findSource($, n) : null
81 const file = source ? await toPng($, source, n) : null
82
83 if (!file) {
84 return { n, file: null, size: null }
85 }
86
87 if (!sizes.has(file)) {
88 // Over the 4 MiB read cap it still draws, just without its aspect ratio
89 const size = await $.fs.read(file, { as: 'bytes' }).then(({ base64 }) => pngSize(base64), () => null)
90 sizes.set(file, size)
91 }
92
93 return { n, file, size: sizes.get(file) ?? null }
94}
95
96async function poll($: EngineInterface): Promise<void> {
97 if (isPolling) {
98 return
99 }
100
101 isPolling = true
102
103 try {
104 const { text } = await $.prompt.read()
105 const known = await read($, previewsAtom)
106 const numbers = placeholders(text)
107
108 if (numbers.length === 0 && known.length === 0) {
109 return
110 }
111
112 const previews = await Promise.all(
113 numbers.map(n => known.find(preview => preview.n === n && preview.file) ?? resolvePreview($, n)),
114 )
115
116 if (!isSame(previews, known)) {
117 await update($, previewsAtom, () => previews)
118 }
119 } finally {
120 isPolling = false
121 }
122}
123
124export const register: Register = on => {
125 on('session.start', async ($, e, next) => {
126 $.clock.every(POLL_MS, () => poll($))
127
128 return next(e)
129 })
130
131 on('prompt.submit', async ($, e, next) => {
132 await update($, previewsAtom, () => [])
133
134 return next(e)
135 })
136
137 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
138 // The desktop composer already shows its own thumbnails
139 if (e.surface !== 'terminal' || e.props.hasSurvey) {
140 return next(e)
141 }
142
143 const previews = await read($, previewsAtom)
144
145 if (previews.length === 0) {
146 return next(e)
147 }
148
149 const { Box, Image, Text } = $.ui.resolve(e)
150 const cells = fitRow(previews.map(preview => preview.size), e.props.maxRows, e.props.bodyColumns)
151 const below = await next(e)
152
153 return (
154 <Box flexDirection="column">
155 <Box flexDirection="row" columnGap={1}>
156 {previews.map((preview, i) => {
157 const { columns, rows } = cells[i] ?? { columns: 6, rows: 1 }
158
159 return (
160 <Box key={`tile-${preview.n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
161 {preview.file ? (
162 <Image
163 key={`image-${preview.n}`}
164 source={{ file: preview.file, format: 'png' }}
165 columns={columns}
166 rows={rows}
167 alt={`[Image #${preview.n}]`}
168 />
169 ) : (
170 <Box width={columns} height={rows} alignItems="center" justifyContent="center">
171 <Text dimColor wrap="truncate">no preview</Text>
172 </Box>
173 )}
174 <Text dimColor>#{preview.n}</Text>
175 </Box>
176 )
177 })}
178 </Box>
179 {below}
180 </Box>
181 )
182 })
183}
184hooks/images.ts 73 lines1import type { Preview, Size } from '../types'
2
3export type Cells = { columns: number; rows: number }
4
5const TILE_ROWS = 8
6const MAX_COLUMNS = 40
7const MIN_COLUMNS = 6
8// A terminal cell is about twice as tall as it is wide
9const CELL_ASPECT = 2
10// A tile's border on each side plus the label row
11const CHROME_ROWS = 3
12const CHROME_COLUMNS = 2
13const GAP = 1
14const UNKNOWN: Size = { width: 16, height: 10 }
15
16// The numbers of the [Image #N] placeholders in a draft, in order, once each
17export function placeholders(text: string): number[] {
18 const numbers = [...text.matchAll(/\[Image #(\d+)\]/g)].map(match => Number(match[1]))
19
20 return [...new Set(numbers)]
21}
22
23// Width and height from the PNG header, null when the bytes are not a PNG
24export function pngSize(base64: string): Size | null {
25 // 32 base64 characters decode to the 24 bytes holding the signature and IHDR size
26 const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
27 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
28
29 if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) {
30 return null
31 }
32
33 const view = new DataView(head.buffer)
34 const width = view.getUint32(16)
35 const height = view.getUint32(20)
36
37 return width > 0 && height > 0 ? { width, height } : null
38}
39
40// A picture box at most `maxRows` tall that keeps the image's aspect ratio
41export function fit(size: Size | null, maxRows: number): Cells {
42 const { width, height } = size ?? UNKNOWN
43 let rows = maxRows
44 let columns = Math.round((rows * CELL_ASPECT * width) / height)
45
46 if (columns > MAX_COLUMNS) {
47 columns = MAX_COLUMNS
48 rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
49 }
50
51 return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, maxRows) }
52}
53
54// The tallest boxes that fit one row of tiles into the band, so it never scrolls
55export function fitRow(sizes: readonly (Size | null)[], bandRows: number, bandColumns: number): Cells[] {
56 const tallest = Math.max(1, Math.min(TILE_ROWS, bandRows - CHROME_ROWS))
57
58 for (let rows = tallest; rows > 1; rows--) {
59 const cells = sizes.map(size => fit(size, rows))
60 const width = cells.reduce((sum, cell) => sum + cell.columns + CHROME_COLUMNS, 0) + GAP * (cells.length - 1)
61
62 if (width <= bandColumns) {
63 return cells
64 }
65 }
66
67 return sizes.map(size => fit(size, 1))
68}
69
70export function isSame(a: Preview[], b: Preview[]): boolean {
71 return a.length === b.length && a.every((preview, i) => preview.n === b[i]?.n && preview.file === b[i]?.file)
72}
73types/index.d.ts 10 lines1export type Size = { width: number; height: number }
2
3export type Preview = { n: number; file: string | null; size: Size | null }
4
5declare module 'claude-code' {
6 interface PluginState {
7 peek: { previews: Preview[] }
8 }
9}
10