SLOPSHOPPER

image-peek

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

newrowsprocess
v0.3.3MITupdated 2026-10-07coolthor/cc-dash-kit/plugins/image-peek
A shopper browsing a rack in a slop shop
README

image-peek

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]. · 繁體中文

Requirements

RequirementWhyIf missing
A terminal that supports the kitty graphics protocol: Ghostty or kittyThe terminal displays the thumbnail inlineA one-line note appears where the thumbnail would be
Python 3 with PillowClipboard pastes reach Claude Code as JPEG; Pillow converts them and scales them down to a PNG thumbnailNo thumbnail; a line in the transcript tells you how to install it
The Claude Code terminal appThe thumbnail is drawn in the terminal transcriptThe 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

Install

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.

Usage

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.

How it works

  1. When a message is added to the session, image-peek looks for images in it: a file path for a pasted file, or the image bytes for a clipboard paste.
  2. hooks/preview.py turns each one into a PNG thumbnail, at most 1024 px on its longest side, in your system temp folder.
  3. When Claude Code renders your message, image-peek adds the thumbnail below it.

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.

Privacy

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.

Troubleshooting

  • No thumbnail and no note: look for a line starting with 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.
  • A one-line note instead of a thumbnail: your terminal does not support the kitty graphics protocol. Try Ghostty or kitty.
  • Images pasted before you installed image-peek have no thumbnail. Thumbnails start with your next paste.

Uninstall

claude plugin uninstall image-peek@cc-dash-kit

Development

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.

License

MIT © coolthor.

Source 2 files
hooks/register.tsx 94 lines
1import { 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}
94
types/index.d.ts 13 lines
1declare 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