SLOPSHOPPER

paste-peek

See the images you paste into the prompt, in real pixels, right above it: switch between them with ⌥←/⌥→, zoom one with ⌥↑, open it in a side pane with ⌥↓…

newpanebandcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paste-peek
│ ┃ paste-peek ✕ › fix the failing auth test and add an audit log call │ ┃ No image yet. Paste one into the prompt. │ ⏺ 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 │ │ › /peek │ ⎿ paste-peek: paste-peek │ │ ▣ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ ⟨Claude Code's own drawing⟩
Pane · paste-peek
No image yet. Paste one into the prompt.
README

paste-peek

See the images you paste into Claude Code, in real pixels, right above the prompt — before you send them.

Works in Ghostty and kitty only (macOS, Claude Code ≥ 2.1.287). Other terminals — Terminal.app, iTerm2, VS Code — show nothing.

中文说明

paste-peek demo: paste screenshots, switch between them, zoom one, open it in the side pane

<sub>An illustrated walkthrough (13 s, MP4); in your terminal the pictures are drawn by Claude Code itself.</sub>

What it does

  • Paste an image (ctrl+v) and a thumbnail appears above the prompt of that session almost at once, captioned [Image #N] like the prompt. Copying alone shows nothing, and other sessions stay quiet.
  • Several images sit side by side; the selected one's caption is highlighted.
  • Zoom the selected image centered above the prompt, or open it in a side pane that fills the screen's height (docked on the right in the fullscreen layout).
  • Send the prompt and the previews clear.

Two ways to look closer

⌥↑ Zoom — pick an image with ⌥← / ⌥→, then press ⌥↑: it opens centered above the prompt, as large as that area allows (about half the screen in the fullscreen layout). ⌥↑ again closes it.

Zoom: the selected image centered above the prompt

⌥↓ Side pane — press ⌥↓ to open the selected image in a pane that fills the screen's height, docked on the right in the fullscreen layout. The thumbnails stay above the prompt and ⌥← / ⌥→ still switch. ⌥↓ again closes it.

Side pane: the selected image large on the right, the thumbnails still above the prompt

Keys

KeyDoes
⌥← ⌥→Select the previous / next image (two or more images)
⌥↑Zoom the selected image, centered above the prompt; again to close
⌥↓Open the selected image in the side pane; again to close
⌥→ (one image)Same as ⌥↓

While zoomed, ⌥← / ⌥→ switch the zoomed image. With no picture in the draft, every key keeps its usual meaning (⌥← / ⌥→ jump by word).

Requirements

  • Claude Code 2.1.287 or later (mods).
  • macOS.
  • Ghostty or kitty, in a foreground session. Pictures use the kitty graphics protocol. Other terminals, and background sessions (claude --bg), draw nothing; the mod checks once per session and stays silent there.
  • python3 (optional) to measure the terminal's cell shape for exact proportions; without it cells are taken as twice as tall as wide.

Limitations

  • The centered zoom is as large as the area above the prompt allows — about half the screen in the fullscreen layout. For a bigger view use the side pane (⌥↓).
  • ⌥↑ / ⌥↓ borrow the diff panel's "previous / next file" chords; while the diff panel is open they belong to it.
  • Right after a paste, the cursor sits at the end of the prompt, where ⌥→ is not reported to mods; press ⌥← once first. The mod then parks the cursor before one trailing space so both directions work.
  • The picture is the file Claude Code saved for that [Image #N]; it shows once Claude Code has written it, a moment after the placeholder appears.

Settings (/config)

SettingDefault
Languageautoauto follows Claude Code's language setting, then the locale; en, zh
Debug logoffWrites what the mod sees (pastes, the pixel check, sizes) to /tmp/paste-peek/debug.log

Commands: /peek opens the pane · /peek why says whether this session can draw pictures · /peek log (debug log on) · /peek clear.

Privacy

Nothing leaves your Mac. The mod makes no network requests and uses no model tokens.

ProgramWhenWhy
idwhen a new [Image #N] appears, until its folder is foundasks for your user id, to locate Claude Code's temp folder
sipsonly if the terminal cannot read filesscales the PNG to send it as bytes
python3when the terminal width changesasks the terminal for its pixel size (cell shape)
sh / mkdir / base64at session startwrites a 1×1 PNG used to check whether pictures can be drawn
rmat session enddeletes the mod's own session folder /tmp/paste-peek/<session id>/ (the probe file)
tail / shonly with Debug log onreads / appends /tmp/paste-peek/debug.log

The mod reads (never copies or changes) the PNG Claude Code saved in its own per-session image folder under /tmp/claude-<uid>/, only to place it and learn its proportions.

The prompt draft is read only to find [Image #N] placeholders; it is not stored.

Install

/plugin marketplace add nokiy/claude-code-mods
/plugin install paste-peek@nokiy-mods

Or try it from a clone: claude --plugin-dir ./plugins/paste-peek.

Develop

claude plugin validate ./plugins/paste-peek
claude plugin test ./plugins/paste-peek
./plugins/paste-peek/docs/render.sh   # re-render the README images and the demo (Chrome + ffmpeg)

Credits

The cell-shape measurement follows hedingerm/image-preview (MIT), which follows Grok Build.

License

MIT

Source 4 files
hooks/register.tsx 407 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ImageSource, Register } from 'claude-code'
3
4import type { PixelMode, Shot, ZoomSite } from '../types'
5import {
6  DEFAULT_ASPECT, DIR, LOG, MEASURE_CELL, TINY_PNG,
7  caption, cycle, fit, headerSize, imageFile, imageRoots, isReadable, missing, nextShots, parkCursor, parseCellAspect, pick, placeholders, probeLine, saysNoPixels, scaledFile, stripRows,
8} from './logic'
9import { pickLang, strings } from './strings'
10import type { Strings } from './strings'
11
12const PANE = 'paste-peek'
13const POLL_MS = 300
14const HISTORY = 10
15const PROBE_TRIES = 3
16const PNG_EDGE = 1600 // longest side sent as base64 when the terminal cannot read files
17
18// Borrowed diff-panel actions: their chords (⌥↑ / ⌥↓, ctrl+↑ / ctrl+↓ by default) press
19// these Buttons straight from the prompt while no diff panel is open to handle them.
20// ⌥← / ⌥→ are taken in prompt.edit instead, and only while the draft holds two or more pictures.
21const ZOOM_ACTION = 'app:diffFileListUp'
22const RIGHT_ACTION = 'app:diffFileListDown'
23
24const shots = atom({ plugin: 'paste-peek', key: 'shots' } as const, [])
25const history = atom({ plugin: 'paste-peek', key: 'history' } as const, [])
26const zoom = atom({ plugin: 'paste-peek', key: 'zoom' } as const, null)
27const selected = atom({ plugin: 'paste-peek', key: 'selected' } as const, null)
28const zoomOpen = atom({ plugin: 'paste-peek', key: 'zoomOpen' } as const, null)
29const pixelMode = atom({ plugin: 'paste-peek', key: 'pixelMode' } as const, null)
30const probeReason = atom({ plugin: 'paste-peek', key: 'probeReason' } as const, null)
31
32// Module vars reset on reload; session.start sets them again.
33let timer: { cancel: () => void } | undefined
34let isDebug = false
35let langOption: unknown = 'auto'
36let t: Strings = strings('en')
37let sessionDir = `${DIR}/unknown` // the probe sentinel and scaled fallback files of this session only
38let isPolling = false
39let probeTries = 0
40let isProbing = false
41const pngCache = new Map<string, string>()
42let cellAspect = DEFAULT_ASPECT
43let measuredAt: number | undefined
44let lastBody = 0 // the band's width, for the pane's requested width
45
46const probeFile = () => `${sessionDir}/probe.png`
47const paneColumns = () => Math.max(40, Math.floor(lastBody * 0.6))
48
49/** One line to the debug log, only when "Debug log" is on in /config. */
50async function note($: EngineInterface, line: string) {
51  if (!isDebug) return
52  const at = new Date(await $.clock.now()).toTimeString().slice(0, 8)
53  await $.process.run(['/bin/sh', '-c', 'mkdir -p "$1" && printf "%s\\n" "$2" >> "$3"', 'sh', DIR, `${at} ${line}`, LOG])
54}
55
56// ── the draft and Claude Code's saved pictures ───────────────────────
57
58const SCAN_GAP_MS = 2000 // while its images dir is not found, look at most this often
59const imageDirs = new Map<string, string>() // session id -> its images dir
60let uid = ''
61let scannedAt = -SCAN_GAP_MS
62
63/**
64 * <tmp root>/<project folder>/<session id>/images. The project folder encodes a cwd that may
65 * have moved, so scan the root's folders for the session id; undefined until Claude Code made it.
66 */
67async function imagesDir($: EngineInterface): Promise<string | undefined> {
68  const session = await $.session.id()
69  const known = imageDirs.get(session)
70  const now = await $.clock.now()
71  if (known || now - scannedAt < SCAN_GAP_MS) return known
72  scannedAt = now
73  uid ||= (await $.process.run(['id', '-u'])).stdout.trim()
74  for (const root of imageRoots(await $.env.get('CLAUDE_CODE_TMPDIR'), uid)) {
75    const dirs = (await $.fs.list(root).catch(() => [])).filter(d => d.kind === 'dir').map(d => `${root}/${d.name}/${session}/images`)
76    for (const dir of dirs) {
77      if (!(await $.fs.exists(dir))) continue
78      imageDirs.set(session, dir)
79      return dir
80    }
81  }
82}
83
84/** The picture of draft placeholder n in dir; undefined while its file is missing or not ready (a later poll retries). */
85async function loadShot($: EngineInterface, dir: string, n: number): Promise<Shot | undefined> {
86  const file = imageFile(dir, n)
87  const stat = await $.fs.stat(file).catch(() => undefined)
88  if (!stat) return undefined
89  const head = isReadable(stat.size) ? await $.fs.read(file, { as: 'bytes' }).then(r => r.base64, () => undefined) : undefined
90  const size = headerSize(stat.size, head)
91  return size ? { n, file, ...size } : undefined
92}
93
94/**
95 * One poll of this session's draft: each [Image #N] without a shot yet takes the file Claude Code
96 * saved for it; a placeholder gone from the draft drops its shot. Written only on a real change,
97 * so an idle poll never redraws the pictures.
98 */
99async function poll($: EngineInterface) {
100  if (isPolling || (await read($, pixelMode)) === 'none') return
101  isPolling = true
102  try {
103    const draft = (await $.prompt.read()).text
104    const list = await read($, shots)
105    const wanted = missing(list, draft)
106    const dir = wanted.length > 0 ? await imagesDir($) : undefined
107    const got = dir ? (await Promise.all(wanted.map(n => loadShot($, dir, n)))).filter(s => s !== undefined) : []
108    if (got.length > 0) await note($, `pasted ${got.map(s => `${caption(s.n)} ${s.width}x${s.height}`).join(', ')}`)
109    if (nextShots(list, got, draft)) await update($, shots, cur => nextShots(cur, got, draft) ?? cur)
110  } finally {
111    isPolling = false
112  }
113}
114
115function ensureTimer($: EngineInterface) {
116  timer ??= $.clock.every(POLL_MS, () => void poll($))
117}
118
119// ── pixels: probed once per session, real pictures only ──────────────
120
121/** The PNG as base64, its longest side cut until it fits the 2 MiB Image cap. */
122async function pngFor($: EngineInterface, file: string): Promise<string | null> {
123  const hit = pngCache.get(file)
124  if (hit) return hit
125  for (let edge = PNG_EDGE; edge >= 200; edge = Math.floor(edge / 2)) {
126    const small = scaledFile(sessionDir, edge, file)
127    const scaled = await $.process.run(['sips', '-Z', String(edge), file, '--out', small])
128    if (scaled.exitCode !== 0) return null
129    const { base64 } = await $.fs.read(small, { as: 'bytes' })
130    if (base64.length <= 2_700_000) {
131      pngCache.set(file, base64)
132      return base64
133    }
134  }
135  return null
136}
137
138async function sourceFor($: EngineInterface, mode: PixelMode | null, file: string): Promise<ImageSource | null> {
139  const png = mode === 'png' ? await pngFor($, file) : null
140  return mode === 'file' ? { file, format: 'png' } : png ? { png } : null
141}
142
143/**
144 * Ask the engine whether an Image draws pixels here: blit the mounted 1×1
145 * sentinel, first as a file, then as bytes. A deny about drawing at all
146 * settles 'none'; any other deny is retried on a later render.
147 */
148async function probePixels($: EngineInterface, requestId: string) {
149  try {
150    const byFile = await $.ui.blit({ requestId, key: 'probe', source: { file: probeFile(), format: 'png', generation: probeTries + 1 } })
151    if (byFile.deny === undefined) return await settle($, 'file', 'file drawn')
152    const byPng = await $.ui.blit({ requestId, key: 'probe', source: { png: TINY_PNG } })
153    if (byPng.deny === undefined) return await settle($, 'png', `file denied (${byFile.deny}), png drawn`)
154    const isSettled = saysNoPixels(byFile.deny) || saysNoPixels(byPng.deny ?? '') || ++probeTries >= PROBE_TRIES
155    if (isSettled) return await settle($, 'none', `file (${byFile.deny}) / png (${byPng.deny})`)
156    await note($, `pixel probe inconclusive, retry: ${byFile.deny}`)
157  } finally {
158    isProbing = false
159  }
160}
161
162async function settle($: EngineInterface, mode: PixelMode, reason: string) {
163  await update($, pixelMode, () => mode)
164  await update($, probeReason, () => reason)
165  await note($, `pixel probe -> ${mode} · ${reason}`)
166}
167
168/** While unprobed: true when a render should mount the sentinel; the probe is scheduled once per try. */
169function wantsProbe($: EngineInterface, requestId: string, mode: PixelMode | null): boolean {
170  if (mode !== null) return false
171  if (!isProbing) {
172    isProbing = true
173    $.clock.after(500, () => void probePixels($, requestId))
174  }
175  return true
176}
177
178/**
179 * Measure the terminal's cell shape again when the width changed (a font zoom changes both).
180 * The cell measurement follows hedingerm/image-preview (MIT), which follows Grok Build.
181 */
182async function syncCellAspect($: EngineInterface, columns: number) {
183  if (columns === measuredAt) return
184  measuredAt = columns
185  const r = await $.process.run(['python3', '-c', MEASURE_CELL], { timeoutMs: 5000 })
186  const measured = parseCellAspect(r.stdout)
187  if (measured === undefined || Math.abs(measured - cellAspect) < 0.01) return
188  cellAspect = measured
189  $.ui.invalidate('ui.render')
190}
191
192// ── keys: selection and 放大 ─────────────────────────────────────────
193
194/** ⌥← / ⌥→: move the selection by step (wrapping); an open 放大 follows it. */
195async function selectBy($: EngineInterface, step: number) {
196  const next = cycle(await read($, shots), await read($, selected), step)
197  if (!next) return
198  await update($, selected, () => next.n)
199  const site = await read($, zoomOpen)
200  if (site !== null) await update($, zoom, () => next.n)
201  if (site === 'right') await $.ui.open({ id: PANE, title: `Image #${next.n}`, columns: paneColumns() })
202}
203
204/** ⌥↑ (center) / ⌥↓ (right): show the selected image there, or close it when it is already there. */
205async function toggleZoom($: EngineInterface, site: ZoomSite) {
206  const open = await read($, zoomOpen)
207  if (open === site) return closeZoom($)
208  const s = pick(await read($, selected), await read($, shots), []) // the selected one, else the newest
209  if (!s) return
210  await update($, zoom, () => s.n)
211  await update($, zoomOpen, () => site)
212  if (site === 'right') await $.ui.open({ id: PANE, title: `Image #${s.n}`, columns: paneColumns() })
213  else if (open === 'right') await $.ui.close({ id: PANE })
214}
215
216async function closeZoom($: EngineInterface) {
217  const open = await read($, zoomOpen)
218  await update($, zoomOpen, () => null)
219  if (open === 'right') await $.ui.close({ id: PANE })
220}
221
222export const register: Register = (on, options) => {
223  isDebug = options.debug === true
224  langOption = options.language
225
226  on('session.start', async ($, e, next) => {
227    const settings = await $.settings.read()
228    t = strings(pickLang(langOption, settings.language, await $.env.get('LANG')))
229    sessionDir = `${DIR}/${await $.session.id()}`
230    await $.command.register({ name: 'peek', description: `Pasted-image preview: ${t.usage}` })
231    // The sentinel the pixel probe blits, in this session's own temp dir.
232    await $.process.run(['/bin/sh', '-c', 'mkdir -p "$1" && printf "%s" "$2" | base64 -D > "$3"', 'sh', sessionDir, TINY_PNG, probeFile()])
233    const kind = (await $.env.get('CLAUDE_CODE_SESSION_KIND')) ?? 'foreground'
234    await note($, `session start ${await $.session.id()} · ${kind} · ${(await $.env.get('TERM_PROGRAM')) ?? '?'}`)
235    ensureTimer($)
236    return next(e)
237  })
238
239  // The session's probe and scaled files are deleted when it ends; /clear keeps the session, so keeps them.
240  on('session.end', async ($, e, next) => {
241    if (e.reason !== 'clear') {
242      imageDirs.clear()
243      await $.process.run(['rm', '-rf', sessionDir])
244    }
245    return next(e)
246  })
247
248  on('prompt.submit', async ($, e, next) => {
249    const sent = await read($, shots)
250    if (sent.length > 0) {
251      await note($, probeLine('submit', { placeholders: placeholders(e.text) }))
252      await update($, history, list => [...list, ...sent].slice(-HISTORY))
253    }
254    await update($, shots, () => [])
255    await update($, selected, () => null)
256    if ((await read($, zoomOpen)) !== null) await closeZoom($)
257    return next(e)
258  })
259
260  on('command.run', { command: 'peek' }, async ($, e) => {
261    ensureTimer($)
262    const verb = e.args.trim().split(/\s+/)[0] ?? ''
263    if (verb === 'log') {
264      if (!isDebug) return { text: t.debugOff }
265      const r = await $.process.run(['tail', '-n', '30', LOG])
266      return { text: r.stdout.trim() || LOG }
267    }
268    if (verb === 'why') {
269      return { text: `pixel mode: ${(await read($, pixelMode)) ?? t.notProbed}\n${(await read($, probeReason)) ?? ''}`.trim() }
270    }
271    if (verb === 'clear') {
272      await update($, shots, () => [])
273      await update($, history, () => [])
274      await update($, pixelMode, () => null)
275      await update($, probeReason, () => null)
276      probeTries = 0
277      return { text: t.cleared }
278    }
279    if (verb !== '') return { text: `${t.unknown}: /peek ${verb}\n${t.usage}` }
280    await update($, zoom, () => null)
281    await $.ui.open({ id: PANE, title: 'paste-peek', focus: true, closeOnEscape: true })
282    return { text: 'paste-peek' }
283  })
284
285  // ⌥← / ⌥→ switch pictures (two or more); with one, ⌥→ opens it on the right and ⌥← stays a word jump.
286  on('prompt.edit', async ($, e, next) => {
287    const k = e.key
288    if (!k || placeholders(e.text).length === 0) return next(e)
289    const count = (await read($, shots)).length
290    if (count === 0) return next(e)
291    const name = k.key.toLowerCase()
292    const isLeft = k.meta === true && !k.ctrl && name.includes('left')
293    const isRight = k.meta === true && !k.ctrl && name.includes('right')
294    if ((isLeft || isRight) && count > 1) {
295      await note($, `switch ${isLeft ? '⌥←' : '⌥→'} at ${e.cursor}/${e.text.length}`)
296      await selectBy($, isLeft ? -1 : 1)
297      return parkCursor(e.text, e.cursor)
298    }
299    if (isRight && count === 1) {
300      await toggleZoom($, 'right')
301      return { text: e.text, cursor: e.cursor }
302    }
303    return next(e)
304  })
305
306  on('ui.close', async ($, e, next) => {
307    if (e.id === PANE && (await read($, zoomOpen)) === 'right') await update($, zoomOpen, () => null)
308    return next(e)
309  })
310
311  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
312    ensureTimer($)
313    if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
314    const { Box, Text, Button, Image } = $.ui.resolve(e)
315    const mode = await read($, pixelMode)
316    const below = await next(e)
317    // Unprobed: mount only the 1×1 sentinel the probe blits (alt is a blank).
318    if (wantsProbe($, e.requestId, mode)) {
319      const sentinel = <Image key="probe" source={{ file: probeFile(), format: 'png' }} columns={1} rows={1} alt=" " />
320      return <Box flexDirection="column">{sentinel}{below}</Box>
321    }
322    if (mode === 'none') return below
323
324    const list = await read($, shots)
325    const sel = pick(await read($, selected), list, [])
326    if (!sel) return below
327    const body = Math.max(20, e.props.bodyColumns)
328    lastBody = body
329    void syncCellAspect($, body)
330    const site = await read($, zoomOpen)
331    const isCenter = site === 'center'
332
333    const picture = async (s: Shot, maxCols: number, maxRows: number, key: string) => {
334      const { columns, rows } = fit(s.width, s.height, maxCols, maxRows, cellAspect)
335      const source = await sourceFor($, mode, s.file)
336      return source ? <Image key={key} source={source} columns={columns} rows={rows} alt={caption(s.n)} /> : <Text dimColor>{caption(s.n)}</Text>
337    }
338    // One line of keys; they are Buttons so the borrowed chords press them from the prompt.
339    const keys = (
340      <Box key="keys" gap={2} height={1} overflow="hidden" flexWrap="nowrap" justifyContent={isCenter ? 'center' : 'flex-start'}>
341        <Button key="zoom" label={isCenter ? t.zoomClose : t.zoom} hotkey="z" action={ZOOM_ACTION} plain autoFocus onPress={() => void toggleZoom($, 'center')} />
342        <Button key="right" label={site === 'right' ? t.sideClose : t.side} hotkey="r" action={RIGHT_ACTION} plain onPress={() => void toggleZoom($, 'right')} />
343        {list.length > 1 && <Text color="suggestion">{t.switch}</Text>}
344      </Box>
345    )
346
347    if (isCenter) {
348      // The selected image alone, as large as the band allows (the band is capped near half the screen), centered.
349      const room = Math.max(3, e.props.maxRows - 2)
350      return (
351        <Box flexDirection="column">
352          <Box justifyContent="center">{await picture(sel, body - 2, room, `big${sel.n}`)}</Box>
353          <Box justifyContent="center" gap={2} height={1} overflow="hidden" flexWrap="nowrap">
354            <Text>{caption(sel.n)}</Text>
355            {keys}
356          </Box>
357          {below}
358        </Box>
359      )
360    }
361
362    // Small thumbnails side by side; selection changes only the caption's color, so nothing moves.
363    const thumbCols = Math.max(8, Math.min(24, Math.floor((body - 2 * list.length) / list.length)))
364    const strip = await Promise.all(list.map(async s => (
365      <Box flexDirection="column">
366        {await picture(s, thumbCols, stripRows(e.props.maxRows), `t${s.n}`)}
367        <Text color={s.n === sel.n ? 'suggestion' : undefined} bold={s.n === sel.n} dimColor={s.n !== sel.n} wrap="truncate">
368          {caption(s.n)}
369        </Text>
370      </Box>
371    )))
372    return (
373      <Box flexDirection="column">
374        <Box gap={2}>{strip}</Box>
375        {keys}
376        {below}
377      </Box>
378    )
379  })
380
381  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
382    if (e.surface !== 'terminal') return next(e)
383    const { Box, Text, Image } = $.ui.resolve(e)
384    const mode = await read($, pixelMode)
385    const past = await read($, history)
386    const s = pick(await read($, zoom), await read($, shots), past)
387    const width = Math.max(20, e.props.bodyColumns) - 2
388    const room = Math.max(6, (e.viewport?.rows ?? 30) - 6)
389    const box = s ? fit(s.width, s.height, width, room, cellAspect) : { columns: 0, rows: 0 }
390    const source = s ? await sourceFor($, mode, s.file) : null
391
392    return (
393      <Box flexDirection="column">
394        {mode === 'none' ? (
395          <Text dimColor>{t.noPixels}</Text>
396        ) : source && s ? (
397          <Image key={`pane${s.n}`} source={source} columns={box.columns} rows={box.rows} alt={caption(s.n)} />
398        ) : (
399          <Text dimColor>{t.noImage}</Text>
400        )}
401        {s && <Text>{caption(s.n)}  <Text color="suggestion" bold>{t.paneKeys}</Text></Text>}
402        {past.length > 0 && <Text dimColor wrap="truncate">{t.sent}: {past.map(x => `#${x.n}`).join(' ')}</Text>}
403      </Box>
404    )
405  })
406}
407
hooks/logic.ts 171 lines
1// Pure helpers: placeholder parsing, Claude Code's image paths, PNG size, sizing, captions.
2import type { Shot } from '../types'
3
4/** Parent of each session's temp dir; the debug log (off by default) lives here too. */
5export const DIR = '/tmp/paste-peek'
6export const LOG = `${DIR}/debug.log`
7
8/** A 1×1 transparent PNG, base64: the sentinel the pixel probe blits. */
9export const TINY_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
10
11/** Every N of `[Image #N]` in a draft, in order. */
12export function placeholders(text: string): number[] {
13  return [...text.matchAll(/\[Image #(\d+)\]/g)].map(m => Number(m[1]))
14}
15
16/** Shots whose placeholder is still in the draft. */
17export function keep(shots: readonly Shot[], draft: string): Shot[] {
18  const live = new Set(placeholders(draft))
19  return shots.filter(s => live.has(s.n))
20}
21
22/** Find shot n among the draft and the history; else the newest. */
23export function pick(n: number | null, shots: readonly Shot[], history: readonly Shot[]): Shot | undefined {
24  return [...history, ...shots].find(s => s.n === n) ?? shots.at(-1) ?? history.at(-1)
25}
26
27/** The shot `step` places from n in the list, wrapping; the first when n is not in it. */
28export function cycle(shots: readonly Shot[], n: number | null, step: number): Shot | undefined {
29  const at = Math.max(0, shots.findIndex(s => s.n === n))
30  return shots[(at + step + shots.length) % shots.length]
31}
32
33/**
34 * The box after a consumed ⌥←/⌥→. A cursor at the very end makes the next ⌥→ a no-op the
35 * editor never reports, so park it before one trailing space: then ⌥→ always moves, and so arrives.
36 */
37export function parkCursor(text: string, cursor: number): { text: string; cursor: number } {
38  if (cursor < text.length) return { text, cursor }
39  return { text: `${text} `, cursor: text.length }
40}
41
42/** Cell height over width assumed until the terminal's is measured. */
43export const DEFAULT_ASPECT = 2
44
45/** Rows for a picture `columns` wide: rows = columns * h / w / aspect, clamped to [1, maxRows]. */
46export function fitRows(width: number, height: number, columns: number, maxRows: number, aspect = DEFAULT_ASPECT): number {
47  if (width <= 0 || height <= 0) return Math.min(maxRows, Math.max(1, Math.round(columns / 4)))
48  return Math.min(maxRows, Math.max(1, Math.round((columns * height) / width / aspect)))
49}
50
51/** Columns that keep the aspect when rows is the binding limit. */
52export function fitColumns(width: number, height: number, rows: number, maxColumns: number, aspect = DEFAULT_ASPECT): number {
53  if (width <= 0 || height <= 0) return maxColumns
54  return Math.min(maxColumns, Math.max(1, Math.round((rows * aspect * width) / height)))
55}
56
57/** The largest box of at most maxColumns × maxRows cells (each ≤ 255) that keeps the picture's shape. */
58export function fit(width: number, height: number, maxColumns: number, maxRows: number, aspect = DEFAULT_ASPECT): { columns: number; rows: number } {
59  const cols = Math.min(255, maxColumns)
60  const rows = fitRows(width, height, cols, Math.min(255, maxRows), aspect)
61  return { columns: fitColumns(width, height, rows, cols, aspect), rows }
62}
63
64/** Cell height over width from TIOCGWINSZ's "rows cols xpixel ypixel"; undefined when bogus (tmux, ssh). */
65export function parseCellAspect(stdout: string): number | undefined {
66  const [rows = 0, columns = 0, width = 0, height = 0] = stdout.trim().split(/\s+/).map(Number)
67  if (!rows || !columns || !width || !height) return undefined
68  const aspect = height / rows / (width / columns)
69  return aspect >= 1.25 && aspect <= 3.4 ? aspect : undefined
70}
71
72/**
73 * python3: walk up to Claude Code's process and ask its tty for the window's
74 * pixel size (TIOCGWINSZ). After hedingerm/image-preview (MIT), which follows Grok Build.
75 */
76export const MEASURE_CELL = `
77import fcntl, os, struct, subprocess, termios
78pid = os.getppid()
79while pid > 1:
80    tty, ppid = (subprocess.run(['ps', '-o', 'tty=,ppid=', '-p', str(pid)], capture_output=True, text=True).stdout.split() + ['?', '0'])[:2]
81    if tty not in ('?', '??'):
82        fd = os.open('/dev/' + tty, os.O_RDONLY | os.O_NOCTTY)
83        print(*struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8))))
84        break
85    pid = int(ppid)
86`
87
88/** A blit deny that is about drawing pixels at all (alt text, no graphics), not about the element. */
89export function saysNoPixels(deny: string): boolean {
90  return /\balt\b|graphic|pixel|kitty|placeholder|cannot draw|can't draw/i.test(deny)
91}
92
93/** The only caption anywhere: the placeholder as the prompt shows it. */
94export function caption(n: number): string {
95  return `[Image #${n}]`
96}
97
98/** One log line, inputs cut so the log stays readable. */
99export function probeLine(kind: string, fields: Record<string, unknown>): string {
100  const body = Object.entries(fields)
101    .map(([k, v]) => `${k}=${JSON.stringify(v)?.slice(0, 120)}`)
102    .join(' ')
103  return `${kind} ${body}`
104}
105
106/** Rows the thumbnails take at most; small, so zoom is visibly larger. */
107const THUMB_ROWS = 6
108
109/** Picture rows for the strip: the band's maxRows minus the caption row and the keys row, in [1, THUMB_ROWS]. */
110export function stripRows(maxRows: number): number {
111  return Math.min(THUMB_ROWS, Math.max(1, maxRows - 2))
112}
113
114/** Base64 of a PNG's first 24 bytes: signature, then the IHDR chunk with width and height. */
115const HEAD_B64 = 32
116
117/** What `$.fs.read` can return at most (4 MiB); a bigger file's size stays unknown. */
118const READ_CAP = 4 * 1024 * 1024
119
120/** Width and height from a PNG's first bytes; null when it is not a PNG (or not fully written yet). */
121export function pngSize(base64: string): { width: number; height: number } | null {
122  if (base64.length < HEAD_B64) return null
123  const b = (Uint8Array as unknown as { fromBase64: (s: string) => Uint8Array }).fromBase64(base64.slice(0, HEAD_B64)) // the engine has it, the lib typings not yet
124  const isPng = [0x89, 0x50, 0x4e, 0x47].every((v, i) => b[i] === v) && String.fromCharCode(b[12]!, b[13]!, b[14]!, b[15]!) === 'IHDR'
125  if (!isPng) return null
126  const view = new DataView(b.buffer, b.byteOffset)
127  return { width: view.getUint32(16), height: view.getUint32(20) }
128}
129
130/** Can `$.fs.read` return a file of this size? */
131export const isReadable = (bytes: number) => bytes <= READ_CAP
132
133/**
134 * A file's size for layout, from its byte count and what reading it gave (undefined = the read failed).
135 * Over the read cap: unknown (0×0, the layout falls back). Any other failure or a short or non-PNG
136 * head: null, the file is not ready and a later poll tries again.
137 */
138export function headerSize(bytes: number, base64: string | undefined): { width: number; height: number } | null {
139  if (!isReadable(bytes)) return { width: 0, height: 0 }
140  return base64 === undefined ? null : pngSize(base64)
141}
142
143/** Where Claude Code keeps its per-user temp: $CLAUDE_CODE_TMPDIR (itself, or its claude-<uid> child), else /tmp/claude-<uid>; no uid, no claude-<uid> root. */
144export function imageRoots(tmpDir: string | undefined, uid: string): string[] {
145  const base = tmpDir?.replace(/\/+$/, '')
146  const own = uid ? [`${base ?? '/tmp'}/claude-${uid}`] : []
147  return base ? [...own, base] : own
148}
149
150/** The saved picture of [Image #n] in a session's images dir. */
151export function imageFile(dir: string, n: number): string {
152  return `${dir}/${n}.png`
153}
154
155/** Where the fallback's scaled copy of a saved picture goes: in the mod's own dir, never in Claude Code's. */
156export function scaledFile(dir: string, edge: number, file: string): string {
157  return `${dir}/scaled-${edge}-${file.split('/').slice(-3).join('_')}`
158}
159
160/** Placeholders in the draft that have no shot yet, each once. */
161export function missing(shots: readonly Shot[], draft: string): number[] {
162  const have = new Set(shots.map(s => s.n))
163  return [...new Set(placeholders(draft))].filter(n => !have.has(n))
164}
165
166/** The shots after a poll, or null when nothing was added or dropped (so nothing is written). */
167export function nextShots(shots: readonly Shot[], got: readonly Shot[], draft: string): Shot[] | null {
168  const next = keep([...shots, ...got], draft)
169  return got.length === 0 && next.length === shots.length ? null : next
170}
171
hooks/strings.ts 54 lines
1// UI strings, English and Chinese; English unless the session's language says Chinese.
2
3export type Lang = 'en' | 'zh'
4
5const EN = {
6  zoom: '⌥↑ Zoom',
7  zoomClose: '⌥↑ Close',
8  side: '⌥↓ Side pane',
9  sideClose: '⌥↓ Close pane',
10  switch: '⌥← ⌥→ Switch',
11  paneKeys: '⌥↓ close · ⌥← ⌥→ switch · ⌥↑ zoom',
12  noPixels: 'This session draws no pictures (a background session, or a terminal without kitty graphics). Use a foreground Ghostty or kitty session.',
13  noImage: 'No image yet. Paste one into the prompt.',
14  sent: 'sent',
15  debugOff: 'The debug log is off. Turn on "Debug log" for paste-peek in /config.',
16  cleared: 'paste-peek cleared (the pixel probe runs again)',
17  usage: '/peek (pane) · /peek why · /peek log · /peek clear',
18  unknown: 'unknown',
19  notProbed: 'not probed yet',
20}
21
22const ZH: typeof EN = {
23  zoom: '⌥↑ 居中放大',
24  zoomClose: '⌥↑ 收起',
25  side: '⌥↓ 右侧放大',
26  sideClose: '⌥↓ 收起右侧',
27  switch: '⌥← ⌥→ 换图',
28  paneKeys: '⌥↓ 收起右侧 · ⌥← ⌥→ 换图 · ⌥↑ 居中放大',
29  noPixels: '这个会话画不了图片(后台会话,或终端不支持 kitty 图形协议)。请在前台的 Ghostty 或 kitty 会话里使用。',
30  noImage: '还没有图片,往输入框粘贴一张。',
31  sent: '已发送',
32  debugOff: '调试日志未开启。在 /config 里打开 paste-peek 的「Debug log」。',
33  cleared: 'paste-peek 已清空(会重新检测能否画图)',
34  usage: '/peek(面板)· /peek why · /peek log · /peek clear',
35  unknown: '未知命令',
36  notProbed: '尚未检测',
37}
38
39export type Strings = typeof EN
40
41export function strings(lang: Lang): Strings {
42  return lang === 'zh' ? ZH : EN
43}
44
45/**
46 * The UI language: the config option when set, else Claude Code's `language`
47 * setting, else the locale; Chinese only when one of them says so.
48 */
49export function pickLang(option: unknown, setting: unknown, locale: string | undefined): Lang {
50  if (option === 'en' || option === 'zh') return option
51  const said = `${typeof setting === 'string' ? setting : ''} ${locale ?? ''}`.toLowerCase()
52  return /chinese|中文|^zh|\szh/.test(said.trim()) || said.includes('zh_') || said.includes('zh-') ? 'zh' : 'en'
53}
54
types/index.d.ts 28 lines
1/** One image pasted into the draft as [Image #n], as Claude Code saved it for this session. */
2export type Shot = {
3  n: number
4  file: string // <tmp>/<project>/<session>/images/<n>.png, Claude Code's own
5  width: number // 0 when unknown; layout only, never shown
6  height: number
7}
8
9/** Where 放大 shows the picture: centered above the prompt, or in the pane on the right. */
10export type ZoomSite = 'center' | 'right'
11
12/** How an Image reaches the terminal here, as probed; none = this session draws no pixels. */
13export type PixelMode = 'file' | 'png' | 'none'
14
15declare module 'claude-code' {
16  interface PluginState {
17    'paste-peek': {
18      shots: Shot[] // images in the current draft
19      history: Shot[] // images sent, newest last
20      zoom: number | null // n shown zoomed
21      selected: number | null // n the keys act on; null = the newest
22      zoomOpen: ZoomSite | null
23      pixelMode: PixelMode | null // null = not probed yet
24      probeReason: string | null // the engine's answer to the probe, for /peek why
25    }
26  }
27}
28