Shows a chip and a card (thumbnail, file name, size, original path) above the prompt for each image dropped onto it. macOS only.

A Claude Code mod that shows a #1 filename chip above the prompt for each dropped image, with a card (filename, pixel size, bytes, original path) and, on kitty-graphics terminals, a thumbnail. The [Image #1] marker alone doesn't tell you what you pasted, so this mod puts the file's name and details one click or hover away.
[Image #1] from [Image #2] before you send.Not for you if you are on Linux or Windows (the mod runs sips, mdfind, and cmp, which are macOS tools), or if your Claude Code is older than v2.1.287.
In Ghostty, dropping an image shows the chip; hovering over or clicking it opens the card with the thumbnail:
<img src="assets/demo-ghostty.gif" width="470" alt="Dropping an image, then hovering and clicking the chip to open the card, in Ghostty">
On terminals without kitty graphics, the same card appears without the thumbnail:
#1 201.jpg
╭──────────────────────────────╮
│ (thumbnail) │
│ 201.jpg │
│ 500× 500 px · 105 KB │
│ ~/Downloads/201.jpg │
╰──────────────────────────────╯
❯ [Image #1]
From the marketplace:
claude plugin marketplace add i-noma-ru/claude-drop-thumb
claude plugin install drop-thumb@claude-drop-thumb
Try it once:
claude --plugin-dir /path/to/claude-drop-thumb
Load every time: Add to env in ~/.claude/settings.json (multiple directories separated by :). Each entry can be the plugin folder itself, as below, or a folder that contains plugin folders (both confirmed in 2.1.292).
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-drop-thumb"
}
}
Takes effect starting from the next Claude Code launch.
plugin)sips, mdfind, and cmp; Linux / Windows not supported)| Terminal | Chip / Card | Thumbnail |
|---|---|---|
| Ghostty, kitty | Shown | Shown |
| Others (Terminal.app, iTerm2, Orca, etc.) | Shown | Not shown (text info only) |
Thumbnails are rendered using Claude Code's Image element. Because Claude Code renders images only when the terminal name is kitty or ghostty, this mod is designed not to display images on other terminals. Orca (an Electron-based terminal app built on xterm.js) does not interpret the kitty protocol's Unicode placeholder method, so forcing images via CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 will result in broken rendering. Not recommended.
#n filename chips right above the prompt.width× height px, file size in bytes, and the path of the original file (the home directory is shown as ~).[Image #n] marker removes the chips and card.PNG, JPEG, GIF, WebP, BMP. Follows the extensions that Claude Code itself accepts as "images" in the prompt (formats like HEIC, TIFF, and SVG do not become [Image #n] in the first place). For GIFs, only the first frame is displayed as a thumbnail.
<CLAUDE_CODE_TMPDIR or /tmp/claude-<uid>>/<project>/<session id>/images/<n>.<ext> and places only an [Image #n] marker in the prompt. Because dropping does not trigger an edit event, the mod inspects the prompt every 200 ms to detect markers.sips (built into macOS) and passed to the Image element as base64.mdfind) for files of the same size and compares contents with cmp to determine the original path. If Spotlight is unavailable, it searches ~/Downloads, ~/Desktop, ~/Pictures, and ~/Documents using find.This section lists everything the plugin executes and touches, for review. Nothing leaves the machine, and the plugin never submits a prompt or puts text into the prompt.
Commands it runs (all built into macOS, always with fixed arguments and local paths):
id -u — only when CLAUDE_CODE_TMPDIR is not set, to build the /tmp/claude-<uid> path.mkdir -m 700 -p <thumbDir> and test -d <thumbDir> -a ! -L <thumbDir> — create and verify the owner-only thumbnail folder.sips -s format png -Z 512 <image> --out <thumb> and sips -g pixelWidth -g pixelHeight <image> — downscale the dropped image and read its dimensions.mdfind "kMDItemFSSize == <bytes>" — ask Spotlight for files of the same size (3 s timeout).find ~/Downloads ~/Desktop ~/Pictures ~/Documents -maxdepth 2 -type f -size <bytes>c — fallback when Spotlight returns nothing (3 s timeout).cmp -s <cached> <candidate> — compare bytes, for at most 5 candidates.What it reads: the prompt text (to find [Image #n] markers), the dropped images in Claude Code's temporary directory, the thumbnails it wrote, and the environment variables CLAUDE_CODE_TMPDIR, HOME, and TERM_PROGRAM. The cmp step reads the bytes of up to 5 same-sized candidate files to tell which one is the original.
What it writes: 512 px PNG thumbnails under drop-thumb/ inside Claude Code's user-specific temporary directory (mode 0700). Nothing else.
What it submits: nothing. The plugin hooks prompt.submit only to clear its own chips when you send a prompt; it never calls the model and never submits or edits a prompt.
No network communication is performed. The mod only reads images in Claude Code's temporary directory and the contents of candidate files with matching sizes (for comparison). It only writes thumbnails placed in drop-thumb/ inside Claude Code's user-specific temporary directory (0700, readable only by the owner). It never writes directly to the shared /tmp root. Running claude plugin validate <directory> lets you inspect the list of hooked events and invoked APIs.
(pasted image).claude plugin validate . # Inspect configuration and invoked APIs
claude plugin test . # Run hooks/*.test.ts
tsc -p . # Type check after loading once (.claude-plugin/types/ is generated)
The approach of "polling the prompt field and using Claude Code's cached image because dropping does not trigger an edit event" was learned from jarrodwatts/claude-image-view (MIT). The code in this repository was written independently and does not include code from that project.
MIT. Full text in LICENSE.
hooks/register.tsx 287 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Thumb } from '../types'
5
6const thumbs = atom({ plugin: 'drop-thumb', key: 'thumbs' } as const, [] as Thumb[])
7// クリックで固定表示中のカードの番号(ホバーが届かない端末向け)
8const open = atom({ plugin: 'drop-thumb', key: 'open' } as const, null as number | null)
9
10// Claude Code は貼り付け・ドロップされた画像を
11// <CLAUDE_CODE_TMPDIR>/<cwd のスラグ>/<セッション id>/images/<n>.png に保存し、
12// プロンプト欄には [Image #n] の印だけを置く。ドロップは編集イベントを起こさないので、
13// 200ms ごとにプロンプト欄を読んで印を探す。
14const POLL_MS = 200
15// サムネイルの置き場は Claude Code のユーザー固有一時フォルダ(0700)の中に作る。
16// 共有の /tmp 直下に固定名で置くと、他ユーザーがシンボリックリンクを仕込める。
17const THUMB_SUBDIR = 'drop-thumb'
18// 端末は絵を実寸より大きく引き伸ばさないので、枠を埋められる画素数で作る(Retina で 16 列 ≈ 450px)
19const THUMB_PX = 512
20const COLUMNS = 16
21// セルの縦横比(幅/高さ)。行数の見積もりに使う
22const CELL_ASPECT = 0.45
23// 元ファイル名の逆引きに使う場所(Spotlight が使えないときの予備)
24const LOOKUP_DIRS = ['Downloads', 'Desktop', 'Pictures', 'Documents']
25// Claude 本体が kitty 画像を描く端末。それ以外では絵を出さず文字だけにする
26const IMAGE_TERMINALS = ['ghostty', 'kitty']
27
28export function imageTagsIn(text: string): number[] {
29 const found: number[] = []
30 for (const m of text.matchAll(/\[Image #(\d+)\]/g)) found.push(Number(m[1]))
31 return [...new Set(found)]
32}
33
34export function sessionSlug(cwd: string): string {
35 return cwd.replace(/[^A-Za-z0-9]/g, '-')
36}
37
38export function formatBytes(n: number): string {
39 if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
40 if (n >= 1024) return `${Math.round(n / 1024)} KB`
41 return `${n} B`
42}
43
44async function findImagesDir($: EngineInterface, tmp: string, id: string): Promise<string | null> {
45 try {
46 const entries = await $.fs.list(tmp)
47 for (const en of entries) {
48 if (en.kind !== 'dir') continue
49 const candidate = `${tmp}/${en.name}/${id}`
50 if (await $.fs.exists(candidate)) return `${candidate}/images`
51 }
52 } catch {
53 // 読めなければ cwd からの推定に任せる
54 }
55 return null
56}
57
58export function abbreviateHome(path: string, home: string): string {
59 if (!home) return path
60 if (path === home) return '~'
61 return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
62}
63
64const failures = new Map<number, number>()
65
66async function poll($: EngineInterface, imagesDir: string, thumbDir: string, home: string): Promise<void> {
67 const { text } = await $.prompt.read()
68 const tags = imageTagsIn(text)
69 const current = await read($, thumbs)
70
71 if (tags.length === 0) {
72 if (current.length > 0) await update($, thumbs, () => [])
73 return
74 }
75
76 const have = new Set(current.map(t => t.n))
77 // 失敗した印は 10 回(約 2 秒)までしか試さない
78 const missing = tags.filter(n => !have.has(n) && (failures.get(n) ?? 0) < 10)
79 const gone = current.some(t => !tags.includes(t.n))
80 if (missing.length === 0 && !gone) return
81
82 // キャッシュは元の拡張子のまま(8.jpg など)なので、番号で探す
83 let entries: { name: string }[] = []
84 try {
85 entries = await $.fs.list(imagesDir)
86 } catch {
87 entries = []
88 }
89 const made: Thumb[] = []
90 for (const n of missing) {
91 const file = entries.find(en => en.name.startsWith(`${n}.`))
92 const t = file ? await makeThumb($, `${imagesDir}/${file.name}`, n, thumbDir, home) : null
93 if (t) made.push(t)
94 else failures.set(n, (failures.get(n) ?? 0) + 1)
95 }
96 await update($, thumbs, prev => {
97 const kept = prev.filter(t => tags.includes(t.n))
98 const known = new Set(kept.map(t => t.n))
99 return [...kept, ...made.filter(t => !known.has(t.n))].sort((a, b) => a.n - b.n)
100 })
101}
102
103async function makeThumb(
104 $: EngineInterface, src: string, n: number, thumbDir: string, home: string,
105): Promise<Thumb | null> {
106 try {
107 if (!(await $.fs.exists(src))) return null
108 const { size: bytes } = await $.fs.stat(src)
109
110 const dest = `${thumbDir}/${hash(src)}.png`
111 const conv = await $.process.run([
112 'sips', '-s', 'format', 'png', '-Z', String(THUMB_PX), src, '--out', dest,
113 ])
114 if (conv.exitCode !== 0) return null
115
116 const dims = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', src])
117 const width = Number(/pixelWidth:\s*(\d+)/.exec(dims.stdout)?.[1] ?? 0)
118 const height = Number(/pixelHeight:\s*(\d+)/.exec(dims.stdout)?.[1] ?? 0)
119 if (!width || !height) return null
120
121 const { base64 } = await $.fs.read(dest, { as: 'bytes' })
122 const path = await findOriginal($, src, bytes, home)
123 const name = path ? path.slice(path.lastIndexOf('/') + 1) : null
124 return { n, path, name, png: base64, width, height, bytes }
125 } catch {
126 return null
127 }
128}
129
130// ドロップされたファイルはキャッシュと同一バイトで保存されるので、サイズで候補を引き、
131// 中身を比べて確定する。クリップボードからの貼り付けは元ファイルが無いので null。
132async function findOriginal($: EngineInterface, cached: string, bytes: number, home: string): Promise<string | null> {
133 const candidates: string[] = []
134 try {
135 const md = await $.process.run(['mdfind', `kMDItemFSSize == ${bytes}`], { timeoutMs: 3000 })
136 for (const line of md.stdout.split('\n')) {
137 const p = line.trim()
138 if (p && !p.startsWith('/tmp/') && !p.startsWith('/private/tmp/')) candidates.push(p)
139 }
140 } catch {
141 // Spotlight が使えない
142 }
143 if (candidates.length === 0 && home) {
144 const dirs = LOOKUP_DIRS.map(d => `${home}/${d}`)
145 try {
146 const found = await $.process.run(
147 ['find', ...dirs, '-maxdepth', '2', '-type', 'f', '-size', `${bytes}c`],
148 { timeoutMs: 3000 },
149 )
150 for (const line of found.stdout.split('\n')) {
151 const p = line.trim()
152 if (p) candidates.push(p)
153 }
154 } catch {
155 // 予備の探索も失敗
156 }
157 }
158 for (const p of candidates.slice(0, 5)) {
159 const same = await $.process.run(['cmp', '-s', cached, p])
160 if (same.exitCode === 0) return p
161 }
162 return null
163}
164
165function hash(s: string): string {
166 let acc = 2166136261
167 for (let i = 0; i < s.length; i++) {
168 acc ^= s.charCodeAt(i)
169 acc = Math.imul(acc, 16777619) >>> 0
170 }
171 return acc.toString(16)
172}
173
174export const register: Register = on => {
175 let busy = false
176 let canDrawImages = false
177 // カードのパス表示でホームを ~ に縮める(利用者名を画面に出さない)
178 let homeDir = ''
179
180 on('session.start', async ($, e, next) => {
181 const started = await next(e)
182 if (e.surface === null) return started
183
184 try {
185 const term = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
186 canDrawImages = IMAGE_TERMINALS.some(t => term.startsWith(t))
187
188 // 既定は /tmp/claude-<uid>(Orca は CLAUDE_CODE_TMPDIR で同じ場所を渡してくる)
189 let tmp = await $.env.get('CLAUDE_CODE_TMPDIR')
190 if (!tmp) {
191 const uid = await $.process.run(['id', '-u'])
192 tmp = `/tmp/claude-${uid.stdout.trim()}`
193 }
194 const id = await $.session.id()
195 // cwd は途中で変わりうるので、一時フォルダ内でセッション id を持つ場所を探す
196 const imagesDir = (await findImagesDir($, tmp, id)) ?? `${tmp}/${sessionSlug(e.cwd)}/${id}/images`
197 const home = (await $.env.get('HOME')) ?? ''
198 homeDir = home
199
200 // 所有者専用で作り、シンボリックリンクにすり替えられていないことを確かめてから使う
201 const thumbDir = `${tmp}/${THUMB_SUBDIR}`
202 await $.process.run(['mkdir', '-m', '700', '-p', thumbDir])
203 const check = await $.process.run(['test', '-d', thumbDir, '-a', '!', '-L', thumbDir])
204 if (check.exitCode !== 0) return started
205
206 $.clock.every(POLL_MS, () => {
207 if (busy) return
208 busy = true
209 void poll($, imagesDir, thumbDir, home)
210 .catch(() => {})
211 .finally(() => {
212 busy = false
213 })
214 })
215 } catch {
216 // 置き場が分からなければ何もしない
217 }
218 return started
219 })
220
221 on('prompt.submit', async ($, e, next) => {
222 // 送信は止めない: 片付けに失敗しても next へ進む
223 await update($, thumbs, () => []).catch(() => {})
224 await update($, open, () => null).catch(() => {})
225 return next(e)
226 })
227
228 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
229 const list = await read($, thumbs)
230 if (e.props.hasSurvey || list.length === 0) {
231 return next(e)
232 }
233
234 const ui = $.ui.resolve(e)
235 const { Box, Button, Text } = ui
236 const Image = 'Image' in ui && canDrawImages ? ui.Image : null
237 const maxRows = Math.max(1, Math.min(10, e.props.maxRows - 3))
238 const opened = await read($, open)
239
240 // 1 行目: チップの列(クリックでカードを固定/解除)。2 行目以降: カード。
241 // カードは帯の中(流れの中)に出す。帯の外(上)に置くと領域で切り取られて見えない。
242 return (
243 <Box flexDirection="column">
244 <Box>
245 {list.map(t => (
246 <Box key={`chip${t.n}`} marginRight={2}>
247 <Button
248 plain
249 dimColor={opened !== t.n}
250 label={`#${t.n} ${t.name ?? '(pasted image)'}`}
251 hover={{ scope: `card${t.n}` }}
252 onPress={() => update($, open, prev => (prev === t.n ? null : t.n))}
253 />
254 </Box>
255 ))}
256 </Box>
257 {list.map(t => {
258 const rows = Math.max(1, Math.min(maxRows, Math.ceil((COLUMNS * t.height * CELL_ASPECT) / t.width)))
259 const label = t.name ?? '(pasted image)'
260 const pinned = opened === t.n
261 return (
262 <Box
263 key={`card${t.n}`}
264 display={pinned ? 'flex' : 'none'}
265 hover={{ scope: `card${t.n}`, display: 'flex' }}
266 flexDirection="column"
267 alignSelf="flex-start"
268 borderStyle="round"
269 borderDimColor
270 paddingX={1}
271 >
272 {Image ? (
273 <Image key={`img${t.n}`} source={{ png: t.png }} columns={COLUMNS} rows={rows} alt={label} />
274 ) : null}
275 <Text>{label}</Text>
276 <Text dimColor>
277 {t.width}× {t.height} px · {formatBytes(t.bytes)}
278 </Text>
279 {t.path ? <Text dimColor>{abbreviateHome(t.path, homeDir)}</Text> : null}
280 </Box>
281 )
282 })}
283 </Box>
284 )
285 })
286}
287types/index.d.ts 17 lines1export type Thumb = {
2 n: number
3 /** 元ファイルの絶対パス(クリップボード貼り付けなど、見つからなければ null) */
4 path: string | null
5 name: string | null
6 png: string
7 width: number
8 height: number
9 bytes: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'drop-thumb': { thumbs: Thumb[]; open: number | null }
15 }
16}
17