Shows a small preview above the prompt of each image pasted into it

A Claude Code mod that shows a small preview of each image you paste into the prompt.
![A thumbnail of a pasted screenshot above the Claude Code prompt, labelled [Image #1]](docs/preview.png)
When you paste a screenshot, Claude Code only puts a placeholder like [Image #1] in the prompt box. paste-peek draws a thumbnail of that image just above the prompt, so you can see what you're about to send. The preview goes away when you delete the placeholder or submit the prompt.
osascript and resizes them with sips, both built into macOS.Clone the repository. The examples below use ~/code/paste-peek; any folder works.
git clone https://github.com/RyanEmslie/paste-peek.git ~/code/paste-peek
Then load it in one of two ways:
claude --plugin-dir ~/code/paste-peekCLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Use the absolute path of your clone, and separate several folders with :. {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/code/paste-peek"
}
}
Paste an image with Ctrl+V as usual. A thumbnail labelled [Image #N] appears above the prompt within about a quarter of a second.
/paste-peek turns previews off or back on.[-] at the right end of the row, or press Ctrl+X Ctrl+A, to collapse it for now. Claude Code draws this control, not the mod.Each thumbnail keeps the image's aspect ratio and is at most 24 columns wide and 8 rows tall, so wide screenshots fill the width and tall ones fill the height. The mod can't read the pixel size of a terminal cell, so it assumes a cell twice as tall as it is wide. In fonts with taller cells, thumbnails come out slightly wide.
If there are more images than fit across the terminal, the row ends in +N.
[Image #N] placeholders against the ones it's already showing./tmp/claude-<uid>/<project>/<session>/images/N.png$TMPDIR/paste-peek/<session>/, and the terminal reads it from there directly. The mod doesn't delete these copies; macOS clears old files from $TMPDIR on its own schedule. To remove them sooner, delete $TMPDIR/paste-peek.claude plugin validate . # what the engine will load, and anything it would refuse
claude plugin test . # the unit and hook tests in hooks/*.test.ts(x)
npx -p typescript tsc -p . --noEmit
Claude Code writes the type declarations into .claude-plugin/types/ each time it loads the mod, so run the mod once before type-checking. That folder is git-ignored.
| File | What it holds |
|---|---|
hooks/register.tsx | The hooks, the thumbnail row, and the commands that copy each image |
hooks/capture.ts | Pure helpers that build the osascript/sips commands and parse their output |
hooks/placeholders.ts | Finding [Image #N] placeholders and image paths, and sizing thumbnails |
types/index.d.ts | The state the mod keeps |
hooks/register.tsx 303 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Thumb } from '../types'
5import type { Captured } from './capture'
6import {
7 cacheDirPath,
8 clipboardFurlArgv,
9 clipboardPngArgv,
10 convertArgv,
11 DEFAULT_MAX_SIDE,
12 engineImagePath,
13 expandHome,
14 mkdirArgv,
15 needsShrink,
16 parseFurl,
17 parseSize,
18 removeArgv,
19 shrinkArgv,
20 sizeArgv,
21 uidArgv,
22} from './capture'
23import { fitCount, pastedImagePaths, placeholderNumbers, thumbBox } from './placeholders'
24
25const thumbs = atom({ plugin: 'paste-peek', key: 'thumbs' } as const, [] as Thumb[])
26const isOff = atom({ plugin: 'paste-peek', key: 'isOff' } as const, false)
27
28const THUMB_COLUMNS = 24
29const THUMB_ROWS = 8
30const POLL_MS = 250
31
32// Module state a reload starts over: the session's cwd and the uid, which
33// name Claude Code's own copy of a pasted image, and the Ns whose capture is
34// scheduled or running, so the poll and an edit never capture one twice.
35let cwd: string | null = null
36let uid: string | null | undefined
37let poller: Timer | null = null
38const inFlight = new Set<number>()
39
40export const register: Register = on => {
41 on('session.start', async ($, e, next) => {
42 await $.command.register({
43 name: 'paste-peek',
44 description: 'Turn the previews of pasted images on or off',
45 })
46 await update($, thumbs, () => [])
47 cwd = e.cwd
48 inFlight.clear()
49 // A Ctrl+V image paste fires no prompt.edit, so the box is read on a
50 // timer for the preview to show without waiting for a keystroke.
51 poller?.cancel()
52 poller = $.clock.every(POLL_MS, () => void poll($))
53
54 return next(e)
55 })
56
57 on('command.run', { command: 'paste-peek' }, async $ => {
58 const off = !(await read($, isOff))
59 await update($, isOff, () => off)
60 if (off) {
61 await update($, thumbs, () => [])
62 }
63
64 return { text: off ? 'Paste previews off.' : 'Paste previews on.' }
65 })
66
67 on('prompt.edit', async ($, e, next) => {
68 const r = await next(e)
69 await sync($, r.text, e.inputText)
70
71 return r
72 })
73
74 on('prompt.submit', async ($, e, next) => {
75 await update($, thumbs, () => [])
76 inFlight.clear()
77
78 return next(e)
79 })
80
81 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82 if (e.surface !== 'terminal' || e.props.hasSurvey) {
83 return next(e)
84 }
85 const list = await read($, thumbs)
86 if (list.length === 0 || (await read($, isOff))) {
87 return next(e)
88 }
89
90 const { Box, Image, Text } = $.ui.resolve(e)
91 const shown = fitCount(list.length, e.props.bodyColumns, THUMB_COLUMNS)
92 const maxRows = Math.max(1, Math.min(THUMB_ROWS, e.props.maxRows - 2))
93
94 return (
95 <Box key="band" flexDirection="row" alignItems="flex-end">
96 {list.slice(0, shown).map(t => {
97 const box = thumbBox(t.width, t.height, THUMB_COLUMNS, maxRows)
98 const name = `[Image #${t.n}]`
99
100 return (
101 <Box key={`thumb-${t.n}`} flexDirection="column" marginRight={2}>
102 {t.status === 'ready' && t.path !== null ? (
103 <Image
104 key={`img-${t.n}`}
105 source={{ file: t.path, format: 'png', generation: t.generation }}
106 columns={box.columns}
107 rows={box.rows}
108 alt={name}
109 />
110 ) : (
111 <Box width={box.columns} height={box.rows} justifyContent="center" alignItems="center">
112 <Text key={`status-${t.n}`} dimColor>
113 {t.status === 'loading' ? '…' : 'no preview'}
114 </Text>
115 </Box>
116 )}
117 <Text key={`label-${t.n}`} dimColor wrap="truncate">
118 {name}
119 </Text>
120 </Box>
121 )
122 })}
123 {shown < list.length && (
124 <Text key="more" dimColor>
125 +{list.length - shown}
126 </Text>
127 )}
128 </Box>
129 )
130 })
131}
132
133function loading(n: number): Thumb {
134 return { n, status: 'loading', path: null, width: 0, height: 0, generation: 0 }
135}
136
137async function poll($: EngineInterface) {
138 try {
139 const box = await $.prompt.read()
140 await sync($, box.text, '')
141 } catch {}
142}
143
144// Keeps the thumbs in step with the `[Image #N]`s in the box: drops the ones
145// gone, adds a loading one for each new N and schedules its capture. Writes
146// nothing when the set is unchanged, as the poll calls it four times a second.
147async function sync($: EngineInterface, text: string, inputText: string) {
148 if (await read($, isOff)) {
149 return
150 }
151
152 const present = placeholderNumbers(text)
153 const known = (await read($, thumbs)).map(t => t.n)
154 const added = present.filter(n => !known.includes(n) && !inFlight.has(n))
155 const isRemoved = known.some(n => !present.includes(n))
156 if (added.length === 0 && !isRemoved) {
157 return
158 }
159 // Claimed before the next await, so a sync running beside this one skips them.
160 for (const n of added) {
161 inFlight.add(n)
162 }
163
164 await update($, thumbs, list => [
165 ...list.filter(t => present.includes(t.n)),
166 ...added.map(n => loading(n)),
167 ])
168 if (added.length > 0) {
169 const paths = pastedImagePaths(inputText)
170 $.clock.after(0, () => void captureAll($, added, paths))
171 }
172}
173
174async function captureAll($: EngineInterface, ns: number[], paths: string[]) {
175 const dir = await cacheDir($)
176 if (dir === null) {
177 await update($, thumbs, list => list.map(t => (ns.includes(t.n) ? { ...t, status: 'failed' } : t)))
178 for (const n of ns) {
179 inFlight.delete(n)
180 }
181 return
182 }
183
184 for (const [i, n] of ns.entries()) {
185 try {
186 const dest = `${dir}/${n}.png`
187 const got = await captureOne($, n, paths[i], dest)
188 const generation = await $.clock.now()
189 await update($, thumbs, list =>
190 list.map(t => {
191 if (t.n !== n) {
192 return t
193 }
194 if (got === null) {
195 return { ...t, status: 'failed' }
196 }
197
198 return { ...t, status: 'ready', path: got.path, width: got.width, height: got.height, generation }
199 }),
200 )
201 } finally {
202 inFlight.delete(n)
203 }
204 }
205}
206
207// The image behind `[Image #n]`, from the first source there is: a path
208// dragged in, Claude Code's own copy of the paste, then the clipboard.
209async function captureOne($: EngineInterface, n: number, src: string | undefined, dest: string): Promise<Captured | null> {
210 if (src !== undefined) {
211 return captureFile($, src, dest)
212 }
213 const own = await engineImage($, n)
214 if (own !== null) {
215 return captureFile($, own, dest)
216 }
217
218 return captureClipboard($, dest)
219}
220
221// The $.process.run calls behind capture.ts's argv builders; they live here
222// because the engine follows $ only into functions of this file.
223
224async function cacheDir($: EngineInterface): Promise<string | null> {
225 try {
226 const dir = cacheDirPath(await $.env.get('TMPDIR'), await $.session.id())
227 const made = await $.process.run(mkdirArgv(dir))
228
229 return made.exitCode === 0 ? dir : null
230 } catch {
231 return null
232 }
233}
234
235async function userId($: EngineInterface): Promise<string | null> {
236 if (uid === undefined) {
237 try {
238 const ran = await $.process.run(uidArgv())
239 uid = ran.exitCode === 0 && ran.stdout.trim() !== '' ? ran.stdout.trim() : null
240 } catch {
241 uid = null
242 }
243 }
244
245 return uid
246}
247
248async function engineImage($: EngineInterface, n: number): Promise<string | null> {
249 try {
250 const id = await userId($)
251 if (id === null || cwd === null) {
252 return null
253 }
254 const path = engineImagePath('/tmp', id, cwd, await $.session.id(), n)
255
256 return (await $.fs.stat(path)).kind === 'file' ? path : null
257 } catch {
258 return null
259 }
260}
261
262async function captureClipboard($: EngineInterface, dest: string): Promise<Captured | null> {
263 try {
264 await $.process.run(removeArgv(dest))
265 const png = await $.process.run(clipboardPngArgv(dest), { timeoutMs: 10_000 })
266 if (png.exitCode !== 0) {
267 const furl = await $.process.run(clipboardFurlArgv(), { timeoutMs: 10_000 })
268 const src = furl.exitCode === 0 ? parseFurl(furl.stdout) : null
269 if (src === null) {
270 return null
271 }
272 await $.process.run(convertArgv(expandHome(src, await $.env.get('HOME')), dest))
273 }
274
275 return await measure($, dest)
276 } catch {
277 return null
278 }
279}
280
281async function captureFile($: EngineInterface, src: string, dest: string): Promise<Captured | null> {
282 try {
283 await $.process.run(removeArgv(dest))
284 await $.process.run(convertArgv(expandHome(src, await $.env.get('HOME')), dest))
285
286 return await measure($, dest)
287 } catch {
288 return null
289 }
290}
291
292// sips exits 0 on a missing source, so reading the size back is the check
293// that a copy was written.
294async function measure($: EngineInterface, dest: string): Promise<Captured | null> {
295 let size = parseSize((await $.process.run(sizeArgv(dest))).stdout)
296 if (size !== null && needsShrink(size.width, size.height, DEFAULT_MAX_SIDE)) {
297 await $.process.run(shrinkArgv(dest, DEFAULT_MAX_SIDE))
298 size = parseSize((await $.process.run(sizeArgv(dest))).stdout)
299 }
300
301 return size === null ? null : { path: dest, ...size }
302}
303hooks/capture.ts 150 lines1// Pure argv builders and output parsers for copying a pasted image to a PNG
2// the terminal can read. The `$.process.run` calls live in register.tsx: the
3// engine follows `$` only into functions of the same file.
4//
5// The order that works (each step's argv from here):
6// removeArgv(dest) sips exits 0 on a missing source, so
7// no stale copy may pass for this one
8// clipboardPngArgv(dest) exit 0: dest written; else no image
9// else clipboardFurlArgv() parseFurl(stdout): a Finder copy
10// then convertArgv(src, dest) src from expandHome(path, HOME)
11// sizeArgv(dest) -> parseSize(stdout) null: nothing usable was written
12// needsShrink(...) ? shrinkArgv(dest, maxSide), then sizeArgv again
13//
14// Before the clipboard, register.tsx tries Claude Code's own copy of the
15// image, engineImagePath(...) with the uid from uidArgv(), through convertArgv.
16
17/** A PNG copy of a pasted image, written where the terminal can read it. */
18export type Captured = { path: string; width: number; height: number }
19
20export const DEFAULT_MAX_SIDE = 600
21
22/** `mkdir -p dir`. */
23export function mkdirArgv(dir: string): string[] {
24 return ['mkdir', '-p', dir]
25}
26
27/** `rm -f path`. */
28export function removeArgv(path: string): string[] {
29 return ['rm', '-f', path]
30}
31
32/**
33 * osascript writing the clipboard's image (a TIFF coerces) to `dest` as PNG;
34 * exits non-zero, leaving no file, when the clipboard holds none.
35 */
36export function clipboardPngArgv(dest: string): string[] {
37 return osascript(
38 [
39 'on run argv',
40 'set dest to item 1 of argv',
41 'set png to the clipboard as «class PNGf»',
42 'set f to open for access (POSIX file dest) with write permission',
43 'try',
44 'set eof f to 0',
45 'write png to f',
46 'close access f',
47 'on error m number n',
48 'close access f',
49 'error m number n',
50 'end try',
51 'end run',
52 ],
53 [dest],
54 )
55}
56
57/**
58 * osascript printing the POSIX path of a file copied in Finder; exits
59 * non-zero when the clipboard holds no file.
60 */
61export function clipboardFurlArgv(): string[] {
62 // Plain text coerces to a file URL too ("notes" reads as "/notes"), so the
63 // clipboard must actually hold one.
64 return osascript([
65 'on run argv',
66 'if (clipboard info for «class furl») is {} then error "no file on the clipboard" number -1700',
67 'return POSIX path of (the clipboard as «class furl»)',
68 'end run',
69 ])
70}
71
72/** `sips` converting `src` to a PNG at `dest`. */
73export function convertArgv(src: string, dest: string): string[] {
74 return ['sips', '-s', 'format', 'png', src, '--out', dest]
75}
76
77/** `sips -Z`, in place; it enlarges a smaller image too, so see needsShrink. */
78export function shrinkArgv(path: string, maxSide: number = DEFAULT_MAX_SIDE): string[] {
79 return ['sips', '-Z', String(Math.max(1, Math.round(maxSide))), path]
80}
81
82/** `sips` printing the pixel size, for parseSize. */
83export function sizeArgv(path: string): string[] {
84 return ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path]
85}
86
87/** Whether an image this size is over `maxSide` on its longest side. */
88export function needsShrink(width: number, height: number, maxSide: number = DEFAULT_MAX_SIDE): boolean {
89 return Math.max(width, height) > maxSide
90}
91
92/** `~` or `~/x` under `home`; any other path, or no home, as written. */
93export function expandHome(path: string, home: string | undefined): string {
94 if (home === undefined || home === '' || (path !== '~' && !path.startsWith('~/'))) {
95 return path
96 }
97
98 return (home.replace(/\/+$/, '') || '/') + path.slice(1)
99}
100
101/**
102 * `<tmpdir>/paste-peek/<session>`: `/tmp` for a missing tmpdir, the session id
103 * made one safe folder name.
104 */
105export function cacheDirPath(tmpdir: string | undefined, sessionId: string): string {
106 const tmp = (tmpdir ?? '').replace(/\/+$/, '') || '/tmp'
107 const name = sessionId.replace(/[^A-Za-z0-9._-]/g, '_')
108 const folder = name === '' || name === '.' || name === '..' ? 'session' : name
109
110 return `${tmp}/paste-peek/${folder}`
111}
112
113/** `id -u`, the uid in the folder Claude Code keeps a session's files under. */
114export function uidArgv(): string[] {
115 return ['id', '-u']
116}
117
118/**
119 * Where Claude Code itself writes the image behind `[Image #n]` the moment it
120 * is pasted: `<tmpRoot>/claude-<uid>/<cwd, each non-alphanumeric a dash>/
121 * <session>/images/<n>.png`. Undocumented, so a capture falls back to the
122 * clipboard when nothing is there.
123 */
124export function engineImagePath(tmpRoot: string, uid: string, cwd: string, sessionId: string, n: number): string {
125 return `${tmpRoot}/claude-${uid.trim()}/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}/images/${n}.png`
126}
127
128/** The size sizeArgv printed; null when it printed none (a missing file). */
129export function parseSize(stdout: string): { width: number; height: number } | null {
130 const width = /pixelWidth:\s*(\d+)/.exec(stdout)?.[1]
131 const height = /pixelHeight:\s*(\d+)/.exec(stdout)?.[1]
132 if (width === undefined || height === undefined) {
133 return null
134 }
135 const size = { width: Number(width), height: Number(height) }
136
137 return size.width > 0 && size.height > 0 ? size : null
138}
139
140/** The absolute path clipboardFurlArgv printed; null for anything else. */
141export function parseFurl(stdout: string): string | null {
142 const path = stdout.replace(/[\r\n]+$/, '')
143
144 return path.startsWith('/') && !path.includes('\n') && path.length > 1 ? path : null
145}
146
147function osascript(lines: readonly string[], args: readonly string[] = []): string[] {
148 return ['osascript', ...lines.flatMap(line => ['-e', line]), ...args]
149}
150hooks/placeholders.ts 134 lines1const PLACEHOLDER = /\[Image #([1-9]\d*)\]/g
2const IMAGE_NAME = /[^/]\.(png|jpe?g|gif|webp|heic|tiff)$/i
3
4/** The N of every `[Image #N]` in the text, in order of appearance, unique. */
5export function placeholderNumbers(text: string): number[] {
6 const seen: number[] = []
7 for (const match of text.matchAll(PLACEHOLDER)) {
8 const n = Number(match[1])
9 if (!seen.includes(n)) {
10 seen.push(n)
11 }
12 }
13
14 return seen
15}
16
17/** The Ns present in `after` but not in `before`. */
18export function addedPlaceholders(before: string, after: string): number[] {
19 const had = placeholderNumbers(before)
20
21 return placeholderNumbers(after).filter(n => !had.includes(n))
22}
23
24/**
25 * The absolute image paths in pasted text (a file dragged into the terminal):
26 * `.png .jpg .jpeg .gif .webp .heic .tiff`, case-insensitive, unquoted with
27 * backslash-escaped spaces, or wrapped in single or double quotes; `~/` kept
28 * as written. Empty when there are none.
29 */
30export function pastedImagePaths(inputText: string): string[] {
31 return words(inputText).filter(isImagePath)
32}
33
34function isImagePath(word: string): boolean {
35 const isAbsolute = word.startsWith('/') || word.startsWith('~/')
36
37 return isAbsolute && IMAGE_NAME.test(word)
38}
39
40// Splits as a shell would for a drag-in: whitespace separates, a backslash
41// escapes the next character, and a quote opens only at the start of a word,
42// so an apostrophe inside ordinary text stays literal.
43function words(input: string): string[] {
44 const out: string[] = []
45 let word = ''
46 let isInWord = false
47 let quote: string | null = null
48
49 for (let i = 0; i < input.length; i++) {
50 const c = input[i] as string
51 if (quote !== null) {
52 if (c === quote) {
53 quote = null
54 } else {
55 word += c
56 }
57 continue
58 }
59 if ((c === '"' || c === "'") && !isInWord) {
60 quote = c
61 isInWord = true
62 continue
63 }
64 if (c === '\\' && i + 1 < input.length) {
65 word += input[i + 1]
66 i++
67 isInWord = true
68 continue
69 }
70 if (/\s/.test(c)) {
71 if (isInWord) {
72 out.push(word)
73 word = ''
74 isInWord = false
75 }
76 continue
77 }
78 word += c
79 isInWord = true
80 }
81 if (isInWord) {
82 out.push(word)
83 }
84
85 return out
86}
87
88/** How one thumbnail is sized in terminal cells. */
89export type ThumbBox = { columns: number; rows: number }
90
91/**
92 * The cell box for an image of `width` x `height` pixels, `columns` wide at
93 * most `maxColumns` (default 24) and `rows` at most `maxRows` (default 8),
94 * keeping the aspect ratio given a cell twice as tall as wide; at least 1x1.
95 * An unknown size (0) answers maxColumns x maxRows / 2.
96 */
97export function thumbBox(width: number, height: number, maxColumns = 24, maxRows = 8): ThumbBox {
98 const columnCap = Math.max(1, Math.floor(maxColumns))
99 const rowCap = Math.max(1, Math.floor(maxRows))
100 if (!(width > 0) || !(height > 0)) {
101 return { columns: columnCap, rows: Math.max(1, Math.floor(rowCap / 2)) }
102 }
103
104 let columns = columnCap
105 let rows = (columns * height) / width / 2
106 if (rows > rowCap) {
107 rows = rowCap
108 columns = (rows * 2 * width) / height
109 }
110
111 return { columns: clamp(Math.round(columns), 1, columnCap), rows: clamp(Math.round(rows), 1, rowCap) }
112}
113
114function clamp(value: number, low: number, high: number): number {
115 return Math.min(high, Math.max(low, value))
116}
117
118/**
119 * How many thumbnails fit side by side in `bodyColumns`, each `thumbColumns`
120 * wide with a 2-cell gap between, leaving room for a `+N` cell (4 columns)
121 * when not all fit; at least 1 when count > 0.
122 */
123export function fitCount(count: number, bodyColumns: number, thumbColumns: number): number {
124 if (!(count > 0)) {
125 return 0
126 }
127 const step = Math.max(1, thumbColumns) + 2
128 if (count * step - 2 <= bodyColumns) {
129 return count
130 }
131
132 return clamp(Math.floor((bodyColumns - 4) / step), 1, count)
133}
134types/index.d.ts 19 lines1/** One pasted image the band previews, keyed by the N of its `[Image #N]`. */
2export type Thumb = {
3 n: number
4 status: 'loading' | 'ready' | 'failed'
5 /** Absolute path of the PNG copy the terminal reads; null until ready. */
6 path: string | null
7 /** Pixel size of that copy; 0 until ready. */
8 width: number
9 height: number
10 /** Bumped whenever the file under `path` is rewritten. */
11 generation: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'paste-peek': { thumbs: Thumb[]; isOff: boolean }
17 }
18}
19