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 ⌥↓…

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.

<sub>An illustrated walkthrough (13 s, MP4); in your terminal the pictures are drawn by Claude Code itself.</sub>
[Image #N] like the prompt. Copying alone shows nothing, and other sessions stay quiet.⌥↑ 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.

⌥↓ 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.

| Key | Does |
|---|---|
| ⌥← ⌥→ | 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).
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.[Image #N]; it shows once Claude Code has written it, a moment after the placeholder appears./config)| Setting | Default | |
|---|---|---|
| Language | auto | auto follows Claude Code's language setting, then the locale; en, zh |
| Debug log | off | Writes 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.
Nothing leaves your Mac. The mod makes no network requests and uses no model tokens.
| Program | When | Why |
|---|---|---|
id | when a new [Image #N] appears, until its folder is found | asks for your user id, to locate Claude Code's temp folder |
sips | only if the terminal cannot read files | scales the PNG to send it as bytes |
python3 | when the terminal width changes | asks the terminal for its pixel size (cell shape) |
sh / mkdir / base64 | at session start | writes a 1×1 PNG used to check whether pictures can be drawn |
rm | at session end | deletes the mod's own session folder /tmp/paste-peek/<session id>/ (the probe file) |
tail / sh | only with Debug log on | reads / 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.
/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.
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)
The cell-shape measurement follows hedingerm/image-preview (MIT), which follows Grok Build.
MIT
hooks/register.tsx 407 lines1import { 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}
407hooks/logic.ts 171 lines1// 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}
171hooks/strings.ts 54 lines1// 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}
54types/index.d.ts 28 lines1/** 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