SLOPSHOPPER

drop-thumb

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

newbandpromptprocesstimer
v0.1.1MITupdated 2026-10-07i-noma-ru/claude-drop-thumb
A shopper browsing a rack in a slop shop
README

claude-drop-thumb

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.

日本語

When to use

  • When you drop several images into one prompt and need to tell [Image #1] from [Image #2] before you send.
  • When you want to check the dimensions, file size, or original path of a dropped image without leaving the prompt.
  • When you use Ghostty or kitty and want a thumbnail of each dropped image right above the prompt.

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.

What it looks like

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]

Install

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.

Requirements

  • Claude Code v2.1.287 or later (mods support; a mod is a kind of Claude Code plugin, and commands and settings call it plugin)
  • macOS (uses sips, mdfind, and cmp; Linux / Windows not supported)

Supported Terminals

TerminalChip / CardThumbnail
Ghostty, kittyShownShown
Others (Terminal.app, iTerm2, Orca, etc.)ShownNot 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.

Behavior

  • Dropping an image displays #n filename chips right above the prompt.
  • Clicking a chip opens the card, and clicking again closes it. It also opens on hover in environments where the terminal passes mouse movement events.
  • Card contents: Thumbnail (kitty-compatible terminals only), filename, width× height px, file size in bytes, and the path of the original file (the home directory is shown as ~).
  • Submitting the prompt or deleting the [Image #n] marker removes the chips and card.

Supported Image Formats

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.

How It Works

  1. Claude Code saves dropped images to a temporary directory <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.
  2. Detected images are downscaled to 512 px PNGs using sips (built into macOS) and passed to the Image element as base64.
  3. Because the saved image has identical bytes to the original file, the mod queries Spotlight (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.

What the plugin runs, reads, writes, and submits

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.

Privacy & Security

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.

Limitations

  • Images pasted directly from the clipboard have no original file, so the chip shows (pasted image).
  • Because the thumbnail line count is estimated assuming a cell aspect ratio of 0.45, vertical padding may remain depending on your terminal and font.
  • When multiple files have identical sizes, the first file whose contents match is treated as the original file.

Development

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)

Acknowledgements

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.

License

MIT. Full text in LICENSE.

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