SLOPSHOPPER

paste-peek

Shows a small preview above the prompt of each image pasted into it

newbandcommandpromptprocesstimer
v0.1.0MITupdated 2026-10-05RyanEmslie/paste-peek
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paste-peek
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /paste-peek ⎿ paste-peek: Paste previews off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

paste-peek

A Claude Code mod that shows a small preview of each image you paste into the prompt.

![A thumbnail of a pasted screenshot above the Claude Code prompt, labelled [Image #1]](docs/preview.png)

When you paste a screenshot, Claude Code only puts a placeholder like [Image #1] in the prompt box. paste-peek draws a thumbnail of that image just above the prompt, so you can see what you're about to send. The preview goes away when you delete the placeholder or submit the prompt.

Requirements

  • macOS. The mod copies images with osascript and resizes them with sips, both built into macOS.
  • A terminal that can draw images with the kitty graphics protocol, such as Ghostty or kitty. Other terminals show the placeholder name as text instead of the picture. Inside tmux, previews also fall back to text.
  • A Claude Code build with function-hook mods. This API is early access and may change between releases. paste-peek was built and tested against Claude Code 2.1.289.

Install

Clone the repository. The examples below use ~/code/paste-peek; any folder works.

git clone https://github.com/RyanEmslie/paste-peek.git ~/code/paste-peek

Then load it in one of two ways:

  • For one session: claude --plugin-dir ~/code/paste-peek
  • For every session: add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Use the absolute path of your clone, and separate several folders with :.
  {
    "env": {
      "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/code/paste-peek"
    }
  }

Usage

Paste an image with Ctrl+V as usual. A thumbnail labelled [Image #N] appears above the prompt within about a quarter of a second.

  • Delete a placeholder and its thumbnail disappears.
  • Submit the prompt and all thumbnails clear.
  • /paste-peek turns previews off or back on.
  • Click [-] at the right end of the row, or press Ctrl+X Ctrl+A, to collapse it for now. Claude Code draws this control, not the mod.

Each thumbnail keeps the image's aspect ratio and is at most 24 columns wide and 8 rows tall, so wide screenshots fill the width and tall ones fill the height. The mod can't read the pixel size of a terminal cell, so it assumes a cell twice as tall as it is wide. In fonts with taller cells, thumbnails come out slightly wide.

If there are more images than fit across the terminal, the row ends in +N.

How it works

  • Spotting pastes. Claude Code doesn't report image pastes as prompt edits. So the mod reads the prompt box every 250 ms, and also on every edit, and compares the [Image #N] placeholders against the ones it's already showing.
  • Finding the image. For each new placeholder, the mod tries these sources in order:
  • a file path you dragged into the prompt
  • the copy Claude Code saves when you paste, at /tmp/claude-<uid>/<project>/<session>/images/N.png
  • the clipboard, or the file a Finder copy refers to
  • Making the thumbnail. The image is converted to PNG and shrunk so its longest side is at most 600 pixels. The copy goes in $TMPDIR/paste-peek/<session>/, and the terminal reads it from there directly. The mod doesn't delete these copies; macOS clears old files from $TMPDIR on its own schedule. To remove them sooner, delete $TMPDIR/paste-peek.

Limitations

  • Undocumented file location. The location of Claude Code's own copy of a pasted image isn't documented, and a future release could move it. If it does, the mod falls back to the clipboard. In that case, recalling an old prompt from history previews whatever is on the clipboard now, not the original image.
  • Terminal only. The desktop app and IDE extensions don't draw the image element this mod uses, so it does nothing there.

Development

claude plugin validate .   # what the engine will load, and anything it would refuse
claude plugin test .       # the unit and hook tests in hooks/*.test.ts(x)
npx -p typescript tsc -p . --noEmit

Claude Code writes the type declarations into .claude-plugin/types/ each time it loads the mod, so run the mod once before type-checking. That folder is git-ignored.

FileWhat it holds
hooks/register.tsxThe hooks, the thumbnail row, and the commands that copy each image
hooks/capture.tsPure helpers that build the osascript/sips commands and parse their output
hooks/placeholders.tsFinding [Image #N] placeholders and image paths, and sizing thumbnails
types/index.d.tsThe state the mod keeps

License

MIT

Source 4 files
hooks/register.tsx 303 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Thumb } from '../types'
5import type { Captured } from './capture'
6import {
7  cacheDirPath,
8  clipboardFurlArgv,
9  clipboardPngArgv,
10  convertArgv,
11  DEFAULT_MAX_SIDE,
12  engineImagePath,
13  expandHome,
14  mkdirArgv,
15  needsShrink,
16  parseFurl,
17  parseSize,
18  removeArgv,
19  shrinkArgv,
20  sizeArgv,
21  uidArgv,
22} from './capture'
23import { fitCount, pastedImagePaths, placeholderNumbers, thumbBox } from './placeholders'
24
25const thumbs = atom({ plugin: 'paste-peek', key: 'thumbs' } as const, [] as Thumb[])
26const isOff = atom({ plugin: 'paste-peek', key: 'isOff' } as const, false)
27
28const THUMB_COLUMNS = 24
29const THUMB_ROWS = 8
30const POLL_MS = 250
31
32// Module state a reload starts over: the session's cwd and the uid, which
33// name Claude Code's own copy of a pasted image, and the Ns whose capture is
34// scheduled or running, so the poll and an edit never capture one twice.
35let cwd: string | null = null
36let uid: string | null | undefined
37let poller: Timer | null = null
38const inFlight = new Set<number>()
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    await $.command.register({
43      name: 'paste-peek',
44      description: 'Turn the previews of pasted images on or off',
45    })
46    await update($, thumbs, () => [])
47    cwd = e.cwd
48    inFlight.clear()
49    // A Ctrl+V image paste fires no prompt.edit, so the box is read on a
50    // timer for the preview to show without waiting for a keystroke.
51    poller?.cancel()
52    poller = $.clock.every(POLL_MS, () => void poll($))
53
54    return next(e)
55  })
56
57  on('command.run', { command: 'paste-peek' }, async $ => {
58    const off = !(await read($, isOff))
59    await update($, isOff, () => off)
60    if (off) {
61      await update($, thumbs, () => [])
62    }
63
64    return { text: off ? 'Paste previews off.' : 'Paste previews on.' }
65  })
66
67  on('prompt.edit', async ($, e, next) => {
68    const r = await next(e)
69    await sync($, r.text, e.inputText)
70
71    return r
72  })
73
74  on('prompt.submit', async ($, e, next) => {
75    await update($, thumbs, () => [])
76    inFlight.clear()
77
78    return next(e)
79  })
80
81  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82    if (e.surface !== 'terminal' || e.props.hasSurvey) {
83      return next(e)
84    }
85    const list = await read($, thumbs)
86    if (list.length === 0 || (await read($, isOff))) {
87      return next(e)
88    }
89
90    const { Box, Image, Text } = $.ui.resolve(e)
91    const shown = fitCount(list.length, e.props.bodyColumns, THUMB_COLUMNS)
92    const maxRows = Math.max(1, Math.min(THUMB_ROWS, e.props.maxRows - 2))
93
94    return (
95      <Box key="band" flexDirection="row" alignItems="flex-end">
96        {list.slice(0, shown).map(t => {
97          const box = thumbBox(t.width, t.height, THUMB_COLUMNS, maxRows)
98          const name = `[Image #${t.n}]`
99
100          return (
101            <Box key={`thumb-${t.n}`} flexDirection="column" marginRight={2}>
102              {t.status === 'ready' && t.path !== null ? (
103                <Image
104                  key={`img-${t.n}`}
105                  source={{ file: t.path, format: 'png', generation: t.generation }}
106                  columns={box.columns}
107                  rows={box.rows}
108                  alt={name}
109                />
110              ) : (
111                <Box width={box.columns} height={box.rows} justifyContent="center" alignItems="center">
112                  <Text key={`status-${t.n}`} dimColor>
113                    {t.status === 'loading' ? '…' : 'no preview'}
114                  </Text>
115                </Box>
116              )}
117              <Text key={`label-${t.n}`} dimColor wrap="truncate">
118                {name}
119              </Text>
120            </Box>
121          )
122        })}
123        {shown < list.length && (
124          <Text key="more" dimColor>
125            +{list.length - shown}
126          </Text>
127        )}
128      </Box>
129    )
130  })
131}
132
133function loading(n: number): Thumb {
134  return { n, status: 'loading', path: null, width: 0, height: 0, generation: 0 }
135}
136
137async function poll($: EngineInterface) {
138  try {
139    const box = await $.prompt.read()
140    await sync($, box.text, '')
141  } catch {}
142}
143
144// Keeps the thumbs in step with the `[Image #N]`s in the box: drops the ones
145// gone, adds a loading one for each new N and schedules its capture. Writes
146// nothing when the set is unchanged, as the poll calls it four times a second.
147async function sync($: EngineInterface, text: string, inputText: string) {
148  if (await read($, isOff)) {
149    return
150  }
151
152  const present = placeholderNumbers(text)
153  const known = (await read($, thumbs)).map(t => t.n)
154  const added = present.filter(n => !known.includes(n) && !inFlight.has(n))
155  const isRemoved = known.some(n => !present.includes(n))
156  if (added.length === 0 && !isRemoved) {
157    return
158  }
159  // Claimed before the next await, so a sync running beside this one skips them.
160  for (const n of added) {
161    inFlight.add(n)
162  }
163
164  await update($, thumbs, list => [
165    ...list.filter(t => present.includes(t.n)),
166    ...added.map(n => loading(n)),
167  ])
168  if (added.length > 0) {
169    const paths = pastedImagePaths(inputText)
170    $.clock.after(0, () => void captureAll($, added, paths))
171  }
172}
173
174async function captureAll($: EngineInterface, ns: number[], paths: string[]) {
175  const dir = await cacheDir($)
176  if (dir === null) {
177    await update($, thumbs, list => list.map(t => (ns.includes(t.n) ? { ...t, status: 'failed' } : t)))
178    for (const n of ns) {
179      inFlight.delete(n)
180    }
181    return
182  }
183
184  for (const [i, n] of ns.entries()) {
185    try {
186      const dest = `${dir}/${n}.png`
187      const got = await captureOne($, n, paths[i], dest)
188      const generation = await $.clock.now()
189      await update($, thumbs, list =>
190        list.map(t => {
191          if (t.n !== n) {
192            return t
193          }
194          if (got === null) {
195            return { ...t, status: 'failed' }
196          }
197
198          return { ...t, status: 'ready', path: got.path, width: got.width, height: got.height, generation }
199        }),
200      )
201    } finally {
202      inFlight.delete(n)
203    }
204  }
205}
206
207// The image behind `[Image #n]`, from the first source there is: a path
208// dragged in, Claude Code's own copy of the paste, then the clipboard.
209async function captureOne($: EngineInterface, n: number, src: string | undefined, dest: string): Promise<Captured | null> {
210  if (src !== undefined) {
211    return captureFile($, src, dest)
212  }
213  const own = await engineImage($, n)
214  if (own !== null) {
215    return captureFile($, own, dest)
216  }
217
218  return captureClipboard($, dest)
219}
220
221// The $.process.run calls behind capture.ts's argv builders; they live here
222// because the engine follows $ only into functions of this file.
223
224async function cacheDir($: EngineInterface): Promise<string | null> {
225  try {
226    const dir = cacheDirPath(await $.env.get('TMPDIR'), await $.session.id())
227    const made = await $.process.run(mkdirArgv(dir))
228
229    return made.exitCode === 0 ? dir : null
230  } catch {
231    return null
232  }
233}
234
235async function userId($: EngineInterface): Promise<string | null> {
236  if (uid === undefined) {
237    try {
238      const ran = await $.process.run(uidArgv())
239      uid = ran.exitCode === 0 && ran.stdout.trim() !== '' ? ran.stdout.trim() : null
240    } catch {
241      uid = null
242    }
243  }
244
245  return uid
246}
247
248async function engineImage($: EngineInterface, n: number): Promise<string | null> {
249  try {
250    const id = await userId($)
251    if (id === null || cwd === null) {
252      return null
253    }
254    const path = engineImagePath('/tmp', id, cwd, await $.session.id(), n)
255
256    return (await $.fs.stat(path)).kind === 'file' ? path : null
257  } catch {
258    return null
259  }
260}
261
262async function captureClipboard($: EngineInterface, dest: string): Promise<Captured | null> {
263  try {
264    await $.process.run(removeArgv(dest))
265    const png = await $.process.run(clipboardPngArgv(dest), { timeoutMs: 10_000 })
266    if (png.exitCode !== 0) {
267      const furl = await $.process.run(clipboardFurlArgv(), { timeoutMs: 10_000 })
268      const src = furl.exitCode === 0 ? parseFurl(furl.stdout) : null
269      if (src === null) {
270        return null
271      }
272      await $.process.run(convertArgv(expandHome(src, await $.env.get('HOME')), dest))
273    }
274
275    return await measure($, dest)
276  } catch {
277    return null
278  }
279}
280
281async function captureFile($: EngineInterface, src: string, dest: string): Promise<Captured | null> {
282  try {
283    await $.process.run(removeArgv(dest))
284    await $.process.run(convertArgv(expandHome(src, await $.env.get('HOME')), dest))
285
286    return await measure($, dest)
287  } catch {
288    return null
289  }
290}
291
292// sips exits 0 on a missing source, so reading the size back is the check
293// that a copy was written.
294async function measure($: EngineInterface, dest: string): Promise<Captured | null> {
295  let size = parseSize((await $.process.run(sizeArgv(dest))).stdout)
296  if (size !== null && needsShrink(size.width, size.height, DEFAULT_MAX_SIDE)) {
297    await $.process.run(shrinkArgv(dest, DEFAULT_MAX_SIDE))
298    size = parseSize((await $.process.run(sizeArgv(dest))).stdout)
299  }
300
301  return size === null ? null : { path: dest, ...size }
302}
303
hooks/capture.ts 150 lines
1// Pure argv builders and output parsers for copying a pasted image to a PNG
2// the terminal can read. The `$.process.run` calls live in register.tsx: the
3// engine follows `$` only into functions of the same file.
4//
5// The order that works (each step's argv from here):
6//   removeArgv(dest)                      sips exits 0 on a missing source, so
7//                                         no stale copy may pass for this one
8//   clipboardPngArgv(dest)                exit 0: dest written; else no image
9//     else clipboardFurlArgv()            parseFurl(stdout): a Finder copy
10//       then convertArgv(src, dest)       src from expandHome(path, HOME)
11//   sizeArgv(dest) -> parseSize(stdout)   null: nothing usable was written
12//   needsShrink(...) ? shrinkArgv(dest, maxSide), then sizeArgv again
13//
14// Before the clipboard, register.tsx tries Claude Code's own copy of the
15// image, engineImagePath(...) with the uid from uidArgv(), through convertArgv.
16
17/** A PNG copy of a pasted image, written where the terminal can read it. */
18export type Captured = { path: string; width: number; height: number }
19
20export const DEFAULT_MAX_SIDE = 600
21
22/** `mkdir -p dir`. */
23export function mkdirArgv(dir: string): string[] {
24  return ['mkdir', '-p', dir]
25}
26
27/** `rm -f path`. */
28export function removeArgv(path: string): string[] {
29  return ['rm', '-f', path]
30}
31
32/**
33 * osascript writing the clipboard's image (a TIFF coerces) to `dest` as PNG;
34 * exits non-zero, leaving no file, when the clipboard holds none.
35 */
36export function clipboardPngArgv(dest: string): string[] {
37  return osascript(
38    [
39      'on run argv',
40      'set dest to item 1 of argv',
41      'set png to the clipboard as «class PNGf»',
42      'set f to open for access (POSIX file dest) with write permission',
43      'try',
44      'set eof f to 0',
45      'write png to f',
46      'close access f',
47      'on error m number n',
48      'close access f',
49      'error m number n',
50      'end try',
51      'end run',
52    ],
53    [dest],
54  )
55}
56
57/**
58 * osascript printing the POSIX path of a file copied in Finder; exits
59 * non-zero when the clipboard holds no file.
60 */
61export function clipboardFurlArgv(): string[] {
62  // Plain text coerces to a file URL too ("notes" reads as "/notes"), so the
63  // clipboard must actually hold one.
64  return osascript([
65    'on run argv',
66    'if (clipboard info for «class furl») is {} then error "no file on the clipboard" number -1700',
67    'return POSIX path of (the clipboard as «class furl»)',
68    'end run',
69  ])
70}
71
72/** `sips` converting `src` to a PNG at `dest`. */
73export function convertArgv(src: string, dest: string): string[] {
74  return ['sips', '-s', 'format', 'png', src, '--out', dest]
75}
76
77/** `sips -Z`, in place; it enlarges a smaller image too, so see needsShrink. */
78export function shrinkArgv(path: string, maxSide: number = DEFAULT_MAX_SIDE): string[] {
79  return ['sips', '-Z', String(Math.max(1, Math.round(maxSide))), path]
80}
81
82/** `sips` printing the pixel size, for parseSize. */
83export function sizeArgv(path: string): string[] {
84  return ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path]
85}
86
87/** Whether an image this size is over `maxSide` on its longest side. */
88export function needsShrink(width: number, height: number, maxSide: number = DEFAULT_MAX_SIDE): boolean {
89  return Math.max(width, height) > maxSide
90}
91
92/** `~` or `~/x` under `home`; any other path, or no home, as written. */
93export function expandHome(path: string, home: string | undefined): string {
94  if (home === undefined || home === '' || (path !== '~' && !path.startsWith('~/'))) {
95    return path
96  }
97
98  return (home.replace(/\/+$/, '') || '/') + path.slice(1)
99}
100
101/**
102 * `<tmpdir>/paste-peek/<session>`: `/tmp` for a missing tmpdir, the session id
103 * made one safe folder name.
104 */
105export function cacheDirPath(tmpdir: string | undefined, sessionId: string): string {
106  const tmp = (tmpdir ?? '').replace(/\/+$/, '') || '/tmp'
107  const name = sessionId.replace(/[^A-Za-z0-9._-]/g, '_')
108  const folder = name === '' || name === '.' || name === '..' ? 'session' : name
109
110  return `${tmp}/paste-peek/${folder}`
111}
112
113/** `id -u`, the uid in the folder Claude Code keeps a session's files under. */
114export function uidArgv(): string[] {
115  return ['id', '-u']
116}
117
118/**
119 * Where Claude Code itself writes the image behind `[Image #n]` the moment it
120 * is pasted: `<tmpRoot>/claude-<uid>/<cwd, each non-alphanumeric a dash>/
121 * <session>/images/<n>.png`. Undocumented, so a capture falls back to the
122 * clipboard when nothing is there.
123 */
124export function engineImagePath(tmpRoot: string, uid: string, cwd: string, sessionId: string, n: number): string {
125  return `${tmpRoot}/claude-${uid.trim()}/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}/images/${n}.png`
126}
127
128/** The size sizeArgv printed; null when it printed none (a missing file). */
129export function parseSize(stdout: string): { width: number; height: number } | null {
130  const width = /pixelWidth:\s*(\d+)/.exec(stdout)?.[1]
131  const height = /pixelHeight:\s*(\d+)/.exec(stdout)?.[1]
132  if (width === undefined || height === undefined) {
133    return null
134  }
135  const size = { width: Number(width), height: Number(height) }
136
137  return size.width > 0 && size.height > 0 ? size : null
138}
139
140/** The absolute path clipboardFurlArgv printed; null for anything else. */
141export function parseFurl(stdout: string): string | null {
142  const path = stdout.replace(/[\r\n]+$/, '')
143
144  return path.startsWith('/') && !path.includes('\n') && path.length > 1 ? path : null
145}
146
147function osascript(lines: readonly string[], args: readonly string[] = []): string[] {
148  return ['osascript', ...lines.flatMap(line => ['-e', line]), ...args]
149}
150
hooks/placeholders.ts 134 lines
1const PLACEHOLDER = /\[Image #([1-9]\d*)\]/g
2const IMAGE_NAME = /[^/]\.(png|jpe?g|gif|webp|heic|tiff)$/i
3
4/** The N of every `[Image #N]` in the text, in order of appearance, unique. */
5export function placeholderNumbers(text: string): number[] {
6  const seen: number[] = []
7  for (const match of text.matchAll(PLACEHOLDER)) {
8    const n = Number(match[1])
9    if (!seen.includes(n)) {
10      seen.push(n)
11    }
12  }
13
14  return seen
15}
16
17/** The Ns present in `after` but not in `before`. */
18export function addedPlaceholders(before: string, after: string): number[] {
19  const had = placeholderNumbers(before)
20
21  return placeholderNumbers(after).filter(n => !had.includes(n))
22}
23
24/**
25 * The absolute image paths in pasted text (a file dragged into the terminal):
26 * `.png .jpg .jpeg .gif .webp .heic .tiff`, case-insensitive, unquoted with
27 * backslash-escaped spaces, or wrapped in single or double quotes; `~/` kept
28 * as written. Empty when there are none.
29 */
30export function pastedImagePaths(inputText: string): string[] {
31  return words(inputText).filter(isImagePath)
32}
33
34function isImagePath(word: string): boolean {
35  const isAbsolute = word.startsWith('/') || word.startsWith('~/')
36
37  return isAbsolute && IMAGE_NAME.test(word)
38}
39
40// Splits as a shell would for a drag-in: whitespace separates, a backslash
41// escapes the next character, and a quote opens only at the start of a word,
42// so an apostrophe inside ordinary text stays literal.
43function words(input: string): string[] {
44  const out: string[] = []
45  let word = ''
46  let isInWord = false
47  let quote: string | null = null
48
49  for (let i = 0; i < input.length; i++) {
50    const c = input[i] as string
51    if (quote !== null) {
52      if (c === quote) {
53        quote = null
54      } else {
55        word += c
56      }
57      continue
58    }
59    if ((c === '"' || c === "'") && !isInWord) {
60      quote = c
61      isInWord = true
62      continue
63    }
64    if (c === '\\' && i + 1 < input.length) {
65      word += input[i + 1]
66      i++
67      isInWord = true
68      continue
69    }
70    if (/\s/.test(c)) {
71      if (isInWord) {
72        out.push(word)
73        word = ''
74        isInWord = false
75      }
76      continue
77    }
78    word += c
79    isInWord = true
80  }
81  if (isInWord) {
82    out.push(word)
83  }
84
85  return out
86}
87
88/** How one thumbnail is sized in terminal cells. */
89export type ThumbBox = { columns: number; rows: number }
90
91/**
92 * The cell box for an image of `width` x `height` pixels, `columns` wide at
93 * most `maxColumns` (default 24) and `rows` at most `maxRows` (default 8),
94 * keeping the aspect ratio given a cell twice as tall as wide; at least 1x1.
95 * An unknown size (0) answers maxColumns x maxRows / 2.
96 */
97export function thumbBox(width: number, height: number, maxColumns = 24, maxRows = 8): ThumbBox {
98  const columnCap = Math.max(1, Math.floor(maxColumns))
99  const rowCap = Math.max(1, Math.floor(maxRows))
100  if (!(width > 0) || !(height > 0)) {
101    return { columns: columnCap, rows: Math.max(1, Math.floor(rowCap / 2)) }
102  }
103
104  let columns = columnCap
105  let rows = (columns * height) / width / 2
106  if (rows > rowCap) {
107    rows = rowCap
108    columns = (rows * 2 * width) / height
109  }
110
111  return { columns: clamp(Math.round(columns), 1, columnCap), rows: clamp(Math.round(rows), 1, rowCap) }
112}
113
114function clamp(value: number, low: number, high: number): number {
115  return Math.min(high, Math.max(low, value))
116}
117
118/**
119 * How many thumbnails fit side by side in `bodyColumns`, each `thumbColumns`
120 * wide with a 2-cell gap between, leaving room for a `+N` cell (4 columns)
121 * when not all fit; at least 1 when count > 0.
122 */
123export function fitCount(count: number, bodyColumns: number, thumbColumns: number): number {
124  if (!(count > 0)) {
125    return 0
126  }
127  const step = Math.max(1, thumbColumns) + 2
128  if (count * step - 2 <= bodyColumns) {
129    return count
130  }
131
132  return clamp(Math.floor((bodyColumns - 4) / step), 1, count)
133}
134
types/index.d.ts 19 lines
1/** One pasted image the band previews, keyed by the N of its `[Image #N]`. */
2export type Thumb = {
3  n: number
4  status: 'loading' | 'ready' | 'failed'
5  /** Absolute path of the PNG copy the terminal reads; null until ready. */
6  path: string | null
7  /** Pixel size of that copy; 0 until ready. */
8  width: number
9  height: number
10  /** Bumped whenever the file under `path` is rewritten. */
11  generation: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'paste-peek': { thumbs: Thumb[]; isOff: boolean }
17  }
18}
19