Shows a thumbnail of each pasted image under the prompt you pasted it into

See the images you paste into Claude Code. image-peek draws a thumbnail of each pasted image right under the prompt you pasted it into, so the transcript shows the picture instead of a bare [Image #1]. · 繁體中文
| Requirement | Why | If missing |
|---|---|---|
| A terminal that supports the kitty graphics protocol: Ghostty or kitty | The terminal displays the thumbnail inline | A one-line note appears where the thumbnail would be |
| Python 3 with Pillow | Clipboard pastes reach Claude Code as JPEG; Pillow converts them and scales them down to a PNG thumbnail | No thumbnail; a line in the transcript tells you how to install it |
| The Claude Code terminal app | The thumbnail is drawn in the terminal transcript | The desktop app and IDE extensions display images as usual |
iTerm2 and Apple Terminal do not support the kitty graphics protocol, so they get the one-line note.
Install Pillow if you don't have it:
python3 -m pip install Pillow
claude plugin marketplace add coolthor/cc-dash-kit
claude plugin install --scope user image-peek@cc-dash-kit
Skip the first line if you already added the cc-dash-kit marketplace. Reload plugins (or restart Claude Code) after installing. Use --scope local instead of --scope user to try it in a single project.
Nothing to configure. Paste an image the way you normally do, either from the clipboard or as a file, and send the prompt. The thumbnail appears under that prompt. Each thumbnail is at most 48 columns wide and 24 rows tall, keeping the image's aspect ratio. If a prompt has several images, each gets its own thumbnail.
hooks/preview.py turns each one into a PNG thumbnail, at most 1024 px on its longest side, in your system temp folder.A pasted file's attachment can reach the mod before or after the prompt it belongs to, so the two are paired when they arrive within 5 seconds of each other.
Thumbnails are written to your system temp folder and never leave your machine. image-peek makes no network requests. It remembers thumbnails for the last 30 messages of the session.
image-peek: in the transcript. It names the problem, such as Pillow not being installed. claude --debug keeps the same lines in the debug log.claude plugin uninstall image-peek@cc-dash-kit
claude plugin validate plugins/image-peek
claude plugin test plugins/image-peek
The test kit cannot drive session.append, so pasting is verified in a live session.
MIT © coolthor.
hooks/register.tsx 94 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4// via: 'path' for a pasted file, 'bytes' for image data inside the prompt
5type Shot = { png: string; width: number; height: number; via: 'path' | 'bytes' }
6
7const MAX_MESSAGES = 30
8// An attachment row and its prompt can arrive in either order; pair them within this window
9const WINDOW_MS = 5_000
10
11const shots = atom({ plugin: 'image-peek', key: 'shots' } as const, {} as Record<string, Shot[]>)
12const lastPrompt = atom({ plugin: 'image-peek', key: 'lastPrompt' } as const, null as null | { uuid: string; at: number })
13const pending = atom({ plugin: 'image-peek', key: 'pending' } as const, null as null | { at: number; shots: Shot[] })
14
15export const register: Register = on => {
16 on('session.append', async ($, e, next) => {
17 const result = await next(e)
18 if (e.agentId !== undefined) return result
19 collectShots($, e).catch(err => $.ui.log(`image-peek: could not read pasted image: ${String(err)}`))
20 return result
21 })
22
23 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
24 const drawn = await next(e)
25 if (e.surface !== 'terminal') return drawn
26 const all = await read($, shots)
27 // A message row's requestId is its uuid with the last group zeroed, so match the first four groups
28 const stem = e.requestId.slice(0, 23)
29 const list = all[e.requestId] ?? Object.entries(all).find(([k]) => k.slice(0, 23) === stem)?.[1]
30 if (!list?.length) return drawn
31 const { Box, Image } = $.ui.resolve(e)
32 const room = (e.viewport?.columns ?? 80) - 4
33 return <Box flexDirection="column">
34 {drawn}
35 {list.map((shot, i) => {
36 // A cell is about twice as tall as it is wide
37 let columns = Math.max(8, Math.min(48, room))
38 let rows = Math.round(columns * shot.height / shot.width / 2)
39 if (rows > 24) { columns = Math.max(8, Math.round(columns * 24 / rows)); rows = 24 }
40 return <Image key={`shot-${i}`} source={{ file: shot.png, format: 'png' }} columns={columns} rows={Math.max(2, rows)} alt="[image preview needs a terminal with kitty graphics, e.g. Ghostty or kitty]" />
41 })}
42 </Box>
43 })
44}
45
46export async function collectShots($: Engine, e: { door: string; uuid: string; message: { type: string; content?: unknown } }) {
47 const now = Date.now()
48 const isPrompt = e.door === 'prompt' && e.message.type === 'user'
49 if (isPrompt) await update($, lastPrompt, () => ({ uuid: e.uuid, at: now }))
50 const blocks = Array.isArray(e.message.content) ? e.message.content as { type?: string; text?: string; source?: { type?: string; data?: string } }[] : []
51 const text = blocks.map(b => b.text ?? '').join('\n')
52 const paths = [...text.matchAll(/\[Image: source: ([^\]]+)\]/g)].map(m => (m[1] ?? '').trim())
53 // A pasted file brings its path; a clipboard paste brings only the bytes inside the prompt
54 const inputs: { arg: string; stdin?: string; via: Shot['via'] }[] = paths.length
55 ? paths.map(arg => ({ arg, via: 'path' as const }))
56 : isPrompt
57 ? blocks.filter(b => b.type === 'image' && b.source?.type === 'base64' && b.source.data).map(b => ({ arg: '-', stdin: b.source?.data as string, via: 'bytes' as const }))
58 : []
59 const script = `${$.plugin.root}/hooks/preview.py`
60 const found: Shot[] = []
61 for (const input of inputs) {
62 const r = await $.process.run(['python3', script, input.arg], { timeoutMs: 15_000, ...(input.stdin ? { stdin: input.stdin } : {}) })
63 if (r.exitCode !== 0) { $.ui.log(`image-peek: ${r.stderr.trim() || `exit ${r.exitCode}`}`); continue }
64 found.push({ ...JSON.parse(r.stdout) as Omit<Shot, 'via'>, via: input.via })
65 }
66 if (isPrompt) {
67 // Claim images from an attachment row that arrived first
68 const waiting = await read($, pending)
69 if (waiting && now - waiting.at <= WINDOW_MS) {
70 found.unshift(...waiting.shots)
71 await update($, pending, () => null)
72 }
73 return attach($, e.uuid, found)
74 }
75 if (!found.length) return
76 const last = await read($, lastPrompt)
77 if (last && now - last.at <= WINDOW_MS) return attach($, last.uuid, found)
78 // The prompt has not arrived yet: hold the images for the next one
79 await update($, pending, () => ({ at: now, shots: found }))
80}
81
82async function attach($: Engine, target: string, found: Shot[]) {
83 if (!found.length) return
84 await update($, shots, all => {
85 // The same image can arrive twice, once as a path and once as bytes: drop the second copy, matched by size
86 const merged = [...(all?.[target] ?? [])]
87 for (const f of found) if (!merged.some(s => s.png === f.png || (s.via !== f.via && s.width === f.width && s.height === f.height))) merged.push(f)
88 const next = { ...all, [target]: merged }
89 const keys = Object.keys(next)
90 for (const k of keys.slice(0, Math.max(0, keys.length - MAX_MESSAGES))) delete next[k]
91 return next
92 })
93}
94types/index.d.ts 13 lines1declare module 'claude-code' {
2 interface PluginState {
3 'image-peek': {
4 // Thumbnails per user message, keyed by the message uuid
5 shots: Record<string, { png: string; width: number; height: number; via: 'path' | 'bytes' }[]>
6 // The latest prompt and when it arrived
7 lastPrompt: { uuid: string; at: number } | null
8 // Images from an attachment row that arrived before its prompt
9 pending: { at: number; shots: { png: string; width: number; height: number; via: 'path' | 'bytes' }[] } | null
10 }
11 }
12}
13