SLOPSHOPPER

image-preview

Preview images pasted into the prompt, before you submit: chips above the prompt and an Images pane open them in your OS viewer

newpanebandcommandtoastprompt
v0.1.3MITupdated 2026-10-07sandipchitale/claude-code-image-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · image-preview
│ ┃ Images ✕ › fix the failing auth test and add an audit log call │ ┃ [ 🧹 ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Images in this session ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No images yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /image-preview │ ⎿ image-preview: Images pane opened. │ │ 📷 Images ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
📷 Images
Pane · Images
[ 🧹 ] Images in this session No images yet.
README

image-preview

A Claude Code mod for previewing images you paste or drop into the prompt.

Why

You paste or drop an image into the prompt, don't submit, and step away. When you come back, the prompt only shows [Image #1], and you can't tell which image that was. Claude Code has no way to preview it from the prompt. This was raised in anthropics/claude-code#68986, which was closed.

This mod fills the gap: the 🖼 Image #N chips above the prompt open the pending image in your OS viewer. It works for images pasted from the clipboard as well as dropped files, before you submit. Type a space after the [Image #N] tag that Claude Code inserts, so the chip appears.

The image-preview mod: the Images pane with a row per image, and the Images button and an Image chip above the prompt

What it does

  • Band above the prompt: a 📷 Images button, plus a 🖼 Image #N chip for each [Image #N] in your draft. Clicking a chip opens that image in the OS viewer (Preview on macOS).
  • Images pane: 📷 Images shows or hides it. It is hidden by default. Each row has:
  • 📂 reveal the file in Finder
  • 📋 copy the file path to the clipboard
  • 👀 open the image in Preview
  • [Image #N] label, which also opens the image; hover the buttons for a tooltip
  • 🧹 at the top clears the list
  • Slash command: /image-preview opens the pane. /image-preview 2 opens image 2 in Preview.

How it works

The mod watches your draft for [Image #N] tags. For a dropped file it remembers the original path. Otherwise it finds the copy Claude Code saved at <session>/images/N.png, by searching the temp folders with find. The chips use whichever is available, and show a notice if neither is.

The Images pane lists the images found on disk. It rescans on every prompt submit, and whenever you open the pane or run /image-preview. 🧹 Clear hides the current images for the rest of the session. Clicking an image's chip brings it back.

Install

Try it from a checkout:

claude --plugin-dir /path/to/image-preview

Or install it from GitHub, inside Claude Code:

/plugin marketplace add sandipchitale/claude-code-image-preview
/plugin install image-preview@image-preview

Then run /reload-plugins (or restart). Choose user scope to use it in every project.

Limits

  • Built and tested on Claude Code v2.1.292 on macOS, with Sonnet 5.5.
  • The chip appears only after you type a space after the [Image #N] tag.
  • Open and reveal try the first command that works on your system:
OSOpenReveal
macOSopenopen -R (selects the file)
Linuxxdg-openxdg-open on the folder (the file is not selected)
Windowscmd /c startexplorer /select,
  • Linux and Windows have not been tested. The commands above are untested there. Only macOS has been tried. Finding the saved images uses find over /private/tmp and /tmp, so Windows would also need a Unix-style find (for example from Git Bash) on its PATH for the pane to list images.
  • The mod API has no hook for clicks on the native [Image #N] widget, so the chips above the prompt stand in for it.
  • The tooltip positions in the pane assume fixed button widths. A terminal that draws emoji at a different width may misplace them.
  • The terminal inverts a Button's colors on hover or focus, and the mod can't turn that off.
  • A button in a pane that doesn't hold focus may need a second click.

License

MIT

Source 4 files
hooks/register.tsx 251 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { PreviewFile } from '../types'
5import { openFile, revealFile } from './os'
6import { droppedImagePath, tokens } from './paths'
7
8const draft = atom({ plugin: 'image-preview', key: 'draft' } as const, '')
9const files = atom({ plugin: 'image-preview', key: 'files' } as const, {} as Record<string, PreviewFile>)
10const origins = atom({ plugin: 'image-preview', key: 'origins' } as const, {} as Record<string, string>)
11// Images the user cleared from the pane, so a rescan doesn't bring them back.
12const cleared = atom({ plugin: 'image-preview', key: 'cleared' } as const, {} as Record<string, boolean>)
13
14// Dropped or pasted file paths not yet matched to an [Image #N] chip.
15let pending: string[] = []
16
17// Icon labels of one width, so the buttons match.
18const PAD = (icon: string) => `  ${icon}  `
19
20async function scan($: any) {
21  const sid = await $.session.id()
22  const r = await $.process.run(['find', '/private/tmp', '/tmp', '-maxdepth', '5', '-path', `*/${sid}/images/*`])
23  const gone = await read($, cleared)
24  const found: Record<string, PreviewFile> = {}
25  for (const line of r.stdout.split('\n')) {
26    const m = /\/images\/(\d+)\.[A-Za-z]+$/.exec(line.trim())
27    if (m && !gone[m[1]]) found[m[1]] = { path: line.trim() }
28  }
29  await update($, files, cur => ({ ...cur, ...found }))
30}
31
32async function locate($: any, n: number) {
33  const sid = await $.session.id()
34  const r = await $.process.run(['find', '/private/tmp', '/tmp', '-maxdepth', '5', '-path', `*/${sid}/images/${n}.*`])
35  const path = r.stdout.split('\n').find((l: string) => l.trim())
36  if (path) {
37    await update($, files, m => ({ ...m, [n]: { path } }))
38  }
39}
40
41const PANE = 'image-preview'
42
43async function clearAll($: any) {
44  const ids = Object.keys(await read($, files))
45  await update($, cleared, m => ({ ...m, ...Object.fromEntries(ids.map(id => [id, true])) }))
46  await update($, files, () => ({}))
47}
48
49async function toggle($: any) {
50  const isUp = (await $.ui.panes()).some((p: any) => p.id === PANE)
51  if (isUp) {
52    await $.ui.close({ id: PANE })
53    return
54  }
55  await scan($)
56  await $.ui.open({ id: PANE, title: 'Images', focus: true })
57}
58
59async function show($: any, n: number) {
60  const run = (argv: string[]) => $.process.run(argv)
61  const origin = (await read($, origins))[n]
62  // The dropped file may have been moved since, so the saved copy is the fallback.
63  if (origin && (await openFile(run, origin))) return true
64  let have = (await read($, files))[n]
65  if (!have) {
66    await locate($, n)
67    have = (await read($, files))[n]
68  }
69  if (have && (await openFile(run, have.path))) return true
70  await $.ui.toast(`Image #${n} isn't saved yet or can't be found`)
71  return false
72}
73
74export const register: Register = on => {
75  on('session.start', async ($, e, next) => {
76    await $.command.register({
77      name: 'image-preview',
78      description: 'Preview a pasted image in a pane: /image-preview [n]',
79    })
80    return next(e)
81  })
82
83  on('command.run', { command: 'image-preview' }, async ($, e: any) => {
84    const arg = Number(String(e.args ?? '').trim())
85    await scan($)
86    if (!arg) {
87      await $.ui.open({ id: PANE, title: 'Images', focus: true })
88      return { text: 'Images pane opened.' }
89    }
90    const n = arg
91    const ok = await show($, n)
92    return { text: ok ? `Opened Image #${n}.` : `Image #${n} not found.` }
93  })
94
95  on('prompt.edit', async ($, e, next) => {
96    const result: any = await next(e)
97    const after: string =
98      result?.text ?? e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end)
99    await update($, draft, () => after)
100    if (after.trim() === '') pending = []
101    const dropped = droppedImagePath(e.inputText)
102    if (dropped) pending.push(dropped)
103    const before = new Set(tokens(e.text))
104    for (const n of tokens(after)) {
105      if (before.has(n)) continue
106      const origin = pending.shift()
107      if (origin) await update($, origins, m => ({ ...m, [n]: origin }))
108    }
109    return result
110  })
111
112  on('prompt.submit', async ($, e, next) => {
113    await update($, draft, () => '')
114    pending = []
115    const sent = await next(e)
116    await scan($).catch(() => {})
117    return sent
118  })
119
120  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121    const { Box, Button } = $.ui.resolve(e)
122    const text = await read($, draft)
123    const ns = [...new Set(tokens(text))]
124    return (
125      <Box flexDirection="row" gap={1}>
126        <Button key="toggle-pane" plain onPress={() => toggle($)}>
127          {'📷 Images'}
128        </Button>
129        {ns.map(n => (
130          <Button
131            key={`img-${n}`}
132            plain
133            onPress={() => show($, n)}
134          >
135            {`🖼  Image #${n}`}
136          </Button>
137        ))}
138      </Box>
139    )
140  })
141
142  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
143    const { Box, Text, Button } = $.ui.resolve(e)
144    const known = await read($, files)
145    const origin = await read($, origins)
146    const shown = (n: number) => origin[n] ?? known[n].path
147    const ids = Object.keys(known).map(Number).sort((a, b) => a - b)
148    // Tooltips are the last children of the root, so nothing paints over them:
149    // an absolute Box paints over siblings before it only. Each joins its
150    // button's hover scope, and sits at a position the fixed layout gives.
151    const ROW0 = 4
152    const ROWH = 3
153    const COLS = [2, 14, 26]
154    const tip = (key: string, top: number, left: number, text: string) => (
155      <Box
156        key={`card-${key}`}
157        position="absolute"
158        top={top}
159        left={left}
160        display="none"
161        hover={{ display: 'flex', scope: key }}
162        borderStyle="round"
163        backgroundColor="inverseText"
164        paddingX={1}
165      >
166        <Text color="text">{text}</Text>
167      </Box>
168    )
169    return (
170      <Box flexDirection="column" paddingX={1} paddingBottom={4}>
171        <Box
172          key="clear-box"
173          hover={{ scope: 'tip-clear' }}
174          alignSelf="flex-start"
175        >
176          <Button key="clear" onPress={() => clearAll($)}>
177            {PAD('🧹')}
178          </Button>
179        </Box>
180        <Text> </Text>
181        <Text bold>Images in this session</Text>
182        <Text> </Text>
183        {ids.length === 0 && <Text dimColor>No images yet.</Text>}
184        {ids.map(n => (
185          <Box
186            key={`row-${n}`}
187            flexDirection="row"
188            alignItems="center"
189            gap={2}
190            paddingX={1}
191            paddingY={1}
192            hover={{ backgroundColor: 'subtle' }}
193          >
194            <Box
195              key={`reveal-box-${n}`}
196              hover={{ scope: `tip-reveal-${n}` }}
197            >
198              <Button
199                key={`reveal-${n}`}
200                onPress={() => revealFile((argv: string[]) => $.process.run(argv), shown(n))}
201              >
202                {PAD('📂')}
203              </Button>
204            </Box>
205            <Box
206              key={`copy-box-${n}`}
207              hover={{ scope: `tip-copy-${n}` }}
208            >
209              <Button
210                key={`copy-${n}`}
211                onPress={(press: any) =>
212                  $.ui.copy({ text: shown(n), surface: press.surface })
213                }
214              >
215                {PAD('📋')}
216              </Button>
217            </Box>
218            <Box
219              key={`view-box-${n}`}
220              hover={{ scope: `tip-view-${n}` }}
221            >
222              <Button key={`view-${n}`} onPress={() => show($, n)}>
223                {PAD('👀')}
224              </Button>
225            </Box>
226            <Button key={`open-${n}`} plain onPress={() => show($, n)}>
227              {`[Image #${n}]`}
228            </Button>
229          </Box>
230        ))}
231        {tip('tip-clear', 1, 1, 'Clear list')}
232        {ids.map((n, i) => {
233          const rowTop = ROW0 + i * ROWH
234          // Below the row, unless the card would run past the bottom edge of the
235          // window as scrolled: then above the button (the card is 3 rows tall,
236          // the button on the row's middle line).
237          const offset = e.props?.scroll?.offset ?? 0
238          const room = e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 24
239          const below =
240            rowTop + ROWH + 3 - offset > room ? rowTop - 2 : rowTop + ROWH
241          return [
242            tip(`tip-reveal-${n}`, below, COLS[0], 'Show in Finder'),
243            tip(`tip-copy-${n}`, below, COLS[1], 'Copy path'),
244            tip(`tip-view-${n}`, below, COLS[2], 'View in OS preview'),
245          ]
246        })}
247      </Box>
248    )
249  })
250}
251
hooks/os.ts 34 lines
1// Opening and revealing a file differs per OS and the mod can't read the OS,
2// so each command is tried in turn and the first that works is used.
3type Run = (argv: string[]) => Promise<{ exitCode: number }>
4
5const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, '') || '/'
6
7async function firstOk(run: Run, attempts: { argv: string[]; trustExit?: boolean }[]) {
8  for (const a of attempts) {
9    try {
10      const r = await run(a.argv)
11      if (r.exitCode === 0 || a.trustExit) return true
12    } catch {
13      // command not installed: try the next one
14    }
15  }
16  return false
17}
18
19export const openArgv = (p: string) => [
20  { argv: ['open', p] },
21  { argv: ['xdg-open', p] },
22  { argv: ['cmd', '/c', 'start', '', p] },
23]
24
25// explorer.exe exits 1 even when it worked, so its attempt is trusted.
26export const revealArgv = (p: string) => [
27  { argv: ['open', '-R', p] },
28  { argv: ['explorer', `/select,${p}`], trustExit: true },
29  { argv: ['xdg-open', dirname(p)] },
30]
31
32export const openFile = (run: Run, p: string) => firstOk(run, openArgv(p))
33export const revealFile = (run: Run, p: string) => firstOk(run, revealArgv(p))
34
hooks/paths.ts 21 lines
1export const tokens = (text: string) =>
2  [...text.matchAll(/\[Image #?(\d+)\]/g)].map(m => Number(m[1]))
3
4const IMAGE_EXT = /\.(png|jpe?g|gif|webp|heic|tiff?|bmp)$/i
5
6// The absolute path of a dropped or pasted image file (plain, quoted, escaped
7// or file://), or null for anything else, including a URL that won't decode.
8export function droppedImagePath(input: string): string | null {
9  let raw = input.trim().replace(/^(['"])(.*)\1$/, '$2')
10  if (raw.startsWith('file://')) {
11    try {
12      raw = decodeURIComponent(raw.slice('file://'.length))
13    } catch {
14      return null
15    }
16  } else {
17    raw = raw.replace(/\\(.)/g, '$1')
18  }
19  return raw.startsWith('/') && raw.length > 1 && IMAGE_EXT.test(raw) ? raw : null
20}
21
types/index.d.ts 13 lines
1export type PreviewFile = { path: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'image-preview': {
6      draft: string
7      files: Record<string, PreviewFile>
8      origins: Record<string, string>
9      cleared: Record<string, boolean>
10    }
11  }
12}
13