SLOPSHOPPER

peek

Thumbnails above the prompt for every image you paste

newbandpromptprocesstimer
v0.1.0MITupdated 2026-10-06PickleBoxer/peek
A shopper browsing a rack in a slop shop
README

peek

A Claude Code mod that shows the images you paste as thumbnails above the prompt, instead of bare [Image #1] tags.

peek showing two pasted images as tiles above the Claude Code prompt

  • Tiles above the prompt: one per pasted image, labelled with the number of its tag. They appear as you paste, keep the image's shape (wide screenshots stay wide, phone shots stay tall) and shrink so the row always fits.
  • Follows the draft: deleting a tag drops its tile, sending the prompt clears the row.
  • PNG, JPEG, GIF and WebP: anything other than PNG is converted once with sips, so those previews are macOS only.

The Desktop app already previews pasted images, so peek draws nothing there.

Requirements

  • Claude Code v2.1.287 or later
  • A terminal that draws images: Ghostty, kitty, iTerm2 or WezTerm. Other terminals show a "no preview" tile.
  • macOS for JPEG, GIF and WebP previews (PNG works everywhere)
  • Inside tmux or screen, Claude Code turns images off. Set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 in your environment, and set -g allow-passthrough on for tmux.

Install

/plugin marketplace add PickleBoxer/claude-plugins
/plugin install peek@pickleboxer

Where the images come from

  • The image cache: Claude Code saves each pasted image to <tmp>/claude-<uid>/<project>/<session>/images/<n>.<ext> and puts an [Image #n] tag in the prompt. peek finds the folder by the session id and draws each file with Claude Code's Image element. The terminal reads the file itself, so the image data never passes through the mod.
  • The prompt: pasting raises no edit event, so peek reads the prompt every 200ms to see which tags are in it.
  • Converted copies of non-PNG images are written next to the originals, inside the cache folder only your user can open.

The cache path is internal to Claude Code, so if a release moves it the tiles say "no preview" until peek is updated.

Development

claude --plugin-dir .          # loads the mod and reloads it on save
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

Credits

Inspired by claude-image-view by Jarrod Watts, which first found that a paste raises no edit event and that the draft has to be polled instead.

License

MIT

Source 3 files
hooks/register.tsx 184 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Preview, Size } from '../types'
5import { fitRow, isSame, placeholders, pngSize } from './images'
6
7// Pasting an image raises no prompt.edit (the placeholder only lands with the next key),
8// so the draft is polled instead
9const POLL_MS = 200
10const EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
11
12const previewsAtom = atom({ plugin: 'peek', key: 'previews' } as const, [] as Preview[])
13
14// Only cached once found: the folder appears with the session's first paste
15let imagesDir: string | null = null
16let isPolling = false
17const sizes = new Map<string, Size | null>()
18
19// Claude Code writes each pasted image to <tmp>/claude-<uid>/<project-slug>/<sessionId>/images/N.<ext>,
20// keeping its original format.
21// The path is internal and may move between releases; when it does, tiles say "no preview".
22async function findImagesDir($: EngineInterface): Promise<string | null> {
23  const sessionId = await $.session.id()
24  const custom = await $.env.get('CLAUDE_CODE_TMPDIR')
25  const roots = custom
26    ? [custom]
27    : (await $.fs.list('/tmp').catch(() => []))
28        .filter(entry => entry.name.startsWith('claude-'))
29        .map(entry => `/tmp/${entry.name}`)
30
31  for (const root of roots) {
32    for (const project of await $.fs.list(root).catch(() => [])) {
33      const dir = `${root}/${project.name}/${sessionId}/images`
34
35      if (project.kind === 'dir' && (await $.fs.exists(dir))) {
36        return dir
37      }
38    }
39  }
40
41  return null
42}
43
44async function findSource($: EngineInterface, n: number): Promise<string | null> {
45  for (const extension of EXTENSIONS) {
46    const file = `${imagesDir}/${n}.${extension}`
47
48    if (await $.fs.exists(file)) {
49      return file
50    }
51  }
52
53  return null
54}
55
56// Image draws PNG only, so other formats get a PNG copy once, through macOS sips.
57// The copy stays inside Claude Code's cache, which only this user can enter.
58async function toPng($: EngineInterface, source: string, n: number): Promise<string | null> {
59  if (source.endsWith('.png')) {
60    return source
61  }
62
63  const dir = `${imagesDir}/peek`
64  const file = `${dir}/${n}.png`
65
66  if (await $.fs.exists(file)) {
67    return file
68  }
69
70  const converted = await $.process.run(['mkdir', '-p', dir])
71    .then(() => $.process.run(['sips', '-s', 'format', 'png', '-Z', '512', source, '--out', file]))
72    .catch(() => null)
73
74  return converted?.exitCode === 0 ? file : null
75}
76
77async function resolvePreview($: EngineInterface, n: number): Promise<Preview> {
78  imagesDir ??= await findImagesDir($)
79
80  const source = imagesDir ? await findSource($, n) : null
81  const file = source ? await toPng($, source, n) : null
82
83  if (!file) {
84    return { n, file: null, size: null }
85  }
86
87  if (!sizes.has(file)) {
88    // Over the 4 MiB read cap it still draws, just without its aspect ratio
89    const size = await $.fs.read(file, { as: 'bytes' }).then(({ base64 }) => pngSize(base64), () => null)
90    sizes.set(file, size)
91  }
92
93  return { n, file, size: sizes.get(file) ?? null }
94}
95
96async function poll($: EngineInterface): Promise<void> {
97  if (isPolling) {
98    return
99  }
100
101  isPolling = true
102
103  try {
104    const { text } = await $.prompt.read()
105    const known = await read($, previewsAtom)
106    const numbers = placeholders(text)
107
108    if (numbers.length === 0 && known.length === 0) {
109      return
110    }
111
112    const previews = await Promise.all(
113      numbers.map(n => known.find(preview => preview.n === n && preview.file) ?? resolvePreview($, n)),
114    )
115
116    if (!isSame(previews, known)) {
117      await update($, previewsAtom, () => previews)
118    }
119  } finally {
120    isPolling = false
121  }
122}
123
124export const register: Register = on => {
125  on('session.start', async ($, e, next) => {
126    $.clock.every(POLL_MS, () => poll($))
127
128    return next(e)
129  })
130
131  on('prompt.submit', async ($, e, next) => {
132    await update($, previewsAtom, () => [])
133
134    return next(e)
135  })
136
137  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
138    // The desktop composer already shows its own thumbnails
139    if (e.surface !== 'terminal' || e.props.hasSurvey) {
140      return next(e)
141    }
142
143    const previews = await read($, previewsAtom)
144
145    if (previews.length === 0) {
146      return next(e)
147    }
148
149    const { Box, Image, Text } = $.ui.resolve(e)
150    const cells = fitRow(previews.map(preview => preview.size), e.props.maxRows, e.props.bodyColumns)
151    const below = await next(e)
152
153    return (
154      <Box flexDirection="column">
155        <Box flexDirection="row" columnGap={1}>
156          {previews.map((preview, i) => {
157            const { columns, rows } = cells[i] ?? { columns: 6, rows: 1 }
158
159            return (
160              <Box key={`tile-${preview.n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
161                {preview.file ? (
162                  <Image
163                    key={`image-${preview.n}`}
164                    source={{ file: preview.file, format: 'png' }}
165                    columns={columns}
166                    rows={rows}
167                    alt={`[Image #${preview.n}]`}
168                  />
169                ) : (
170                  <Box width={columns} height={rows} alignItems="center" justifyContent="center">
171                    <Text dimColor wrap="truncate">no preview</Text>
172                  </Box>
173                )}
174                <Text dimColor>#{preview.n}</Text>
175              </Box>
176            )
177          })}
178        </Box>
179        {below}
180      </Box>
181    )
182  })
183}
184
hooks/images.ts 73 lines
1import type { Preview, Size } from '../types'
2
3export type Cells = { columns: number; rows: number }
4
5const TILE_ROWS = 8
6const MAX_COLUMNS = 40
7const MIN_COLUMNS = 6
8// A terminal cell is about twice as tall as it is wide
9const CELL_ASPECT = 2
10// A tile's border on each side plus the label row
11const CHROME_ROWS = 3
12const CHROME_COLUMNS = 2
13const GAP = 1
14const UNKNOWN: Size = { width: 16, height: 10 }
15
16// The numbers of the [Image #N] placeholders in a draft, in order, once each
17export function placeholders(text: string): number[] {
18  const numbers = [...text.matchAll(/\[Image #(\d+)\]/g)].map(match => Number(match[1]))
19
20  return [...new Set(numbers)]
21}
22
23// Width and height from the PNG header, null when the bytes are not a PNG
24export function pngSize(base64: string): Size | null {
25  // 32 base64 characters decode to the 24 bytes holding the signature and IHDR size
26  const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
27  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
28
29  if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) {
30    return null
31  }
32
33  const view = new DataView(head.buffer)
34  const width = view.getUint32(16)
35  const height = view.getUint32(20)
36
37  return width > 0 && height > 0 ? { width, height } : null
38}
39
40// A picture box at most `maxRows` tall that keeps the image's aspect ratio
41export function fit(size: Size | null, maxRows: number): Cells {
42  const { width, height } = size ?? UNKNOWN
43  let rows = maxRows
44  let columns = Math.round((rows * CELL_ASPECT * width) / height)
45
46  if (columns > MAX_COLUMNS) {
47    columns = MAX_COLUMNS
48    rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
49  }
50
51  return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, maxRows) }
52}
53
54// The tallest boxes that fit one row of tiles into the band, so it never scrolls
55export function fitRow(sizes: readonly (Size | null)[], bandRows: number, bandColumns: number): Cells[] {
56  const tallest = Math.max(1, Math.min(TILE_ROWS, bandRows - CHROME_ROWS))
57
58  for (let rows = tallest; rows > 1; rows--) {
59    const cells = sizes.map(size => fit(size, rows))
60    const width = cells.reduce((sum, cell) => sum + cell.columns + CHROME_COLUMNS, 0) + GAP * (cells.length - 1)
61
62    if (width <= bandColumns) {
63      return cells
64    }
65  }
66
67  return sizes.map(size => fit(size, 1))
68}
69
70export function isSame(a: Preview[], b: Preview[]): boolean {
71  return a.length === b.length && a.every((preview, i) => preview.n === b[i]?.n && preview.file === b[i]?.file)
72}
73
types/index.d.ts 10 lines
1export type Size = { width: number; height: number }
2
3export type Preview = { n: number; file: string | null; size: Size | null }
4
5declare module 'claude-code' {
6  interface PluginState {
7    peek: { previews: Preview[] }
8  }
9}
10