Paste an image and a tile appears above the prompt for each [Image #N]: a picture of Claude Code's own copy of the paste, and an [open] button that shows it…

Paste an image into Claude Code with Alt+V and an [open] button appears above the prompt for each [Image #N]. Pressing it opens the full-size picture in your default image viewer. There is no thumbnail.
How it works: the mod saves the clipboard picture as a PNG in your temp folder (image-open subfolder), then opens that file. PNG copies older than a day are deleted from that folder.
Windows only: the save step uses PowerShell.
Install: /plugin install image-open@rmahwj-mods, then /reload-plugins.
hooks/register.tsx 418 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import { blockCells, drawsPictures, fitRow, imageNumbers, pngSize } from './layout'
6import { decodeThumb } from './png'
7
8// One entry per [Image #N] token now in the prompt box.
9const images = atom({ plugin: 'image-open', key: 'images' } as const, [])
10
11// The evidence trail lives in the temp folder (see logFile): it outlives a session, stays out of the vault, and sits
12// outside the mod folder, so writing it never trips the hot-reload watch.
13const MAX_LINES = 300
14// How often the box is read. Pasting raises no prompt.edit (the token turns up after the paste), so it is polled.
15const POLL_MS = 200
16// How many polls a token waits for Claude Code's copy of the paste before its tile says there is none (about 3 s).
17const FIND_TRIES = 15
18
19const lines: string[] = []
20let writing: Promise<void> = Promise.resolve()
21let isSyncing = false
22let logPath: string | undefined
23// Claude Code's temp folder, and this session's images folder inside it once found.
24let tmpRoot: string | undefined
25let found: { sessionId: string; dir: string } | undefined
26// How many polls each token has waited for its file.
27const tries = new Map<number, number>()
28// The `thumbnails` option. Off, the band is the label and [open] alone, as in 0.1.
29let isThumbs = true
30// Read once a load: Windows or not, and whether Claude Code draws real pixels in this terminal.
31let isWindows: boolean | undefined
32let isPictures: boolean | undefined
33
34/** JSON-quote a string so control and invisible characters show; clip it, keeping the true length. */
35function show(s: string, n = 200): string {
36 const j = JSON.stringify(s)
37 return j.length > n ? `${j.slice(0, n)}…(${s.length} chars)` : j
38}
39
40/** Forward slashes, no trailing one. */
41function slashes(p: string): string {
42 return p.replace(/\\/g, '/').replace(/\/+$/, '')
43}
44
45/** Windows or not. A hooks module has no platform call, and Windows always sets OS=Windows_NT. */
46async function onWindows($: EngineInterface): Promise<boolean> {
47 isWindows ??= (await $.env.get('OS')) === 'Windows_NT'
48 return isWindows
49}
50
51/** Whether Claude Code draws `Image` as pixels here (kitty, Ghostty): it does not say, so go by what it decides from. */
52async function canDrawPictures($: EngineInterface): Promise<boolean> {
53 // Each name spelled out: the engine lists the variables a module reads from its literal calls.
54 if (isPictures === undefined) {
55 isPictures = drawsPictures({
56 IMAGE_OPEN_RENDERER: await $.env.get('IMAGE_OPEN_RENDERER'),
57 CLAUDE_CODE_FORCE_TERMINAL_IMAGES: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
58 CLAUDE_CODE_SESSION_KIND: await $.env.get('CLAUDE_CODE_SESSION_KIND'),
59 TMUX: await $.env.get('TMUX'),
60 STY: await $.env.get('STY'),
61 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
62 GHOSTTY_RESOURCES_DIR: await $.env.get('GHOSTTY_RESOURCES_DIR'),
63 TERM: await $.env.get('TERM'),
64 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
65 })
66 }
67 return isPictures
68}
69
70/** Where the log goes: `<temp>/image-open/image-open.log`, forward slashes. Beside the mod only if there is no temp folder. */
71async function logFile($: EngineInterface): Promise<string> {
72 if (logPath === undefined) {
73 const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? (await $.env.get('TMPDIR'))
74 logPath = temp === undefined ? `${$.plugin.root}/image-open.log` : `${slashes(temp)}/image-open/image-open.log`
75 }
76 return logPath
77}
78
79/** Start the in-memory log from what an earlier session left in the file, so the file keeps accumulating. */
80async function seedLog($: EngineInterface): Promise<void> {
81 if (lines.length > 0) return
82 try {
83 const old = await $.fs.read(await logFile($))
84 if (typeof old === 'string') lines.push(...old.split('\n').filter(l => l.trim() !== '').slice(-MAX_LINES))
85 } catch {
86 // No earlier log: start a new one.
87 }
88}
89
90/** Append one line to the log file. Never throws: logging must not get in the prompt's way. */
91async function record($: EngineInterface, kind: string, detail: string): Promise<void> {
92 try {
93 const at = new Date(await $.clock.now()).toISOString().slice(11, 23)
94 lines.push(`${at} ${kind.padEnd(10)} ${detail}`)
95 if (lines.length > MAX_LINES) lines.splice(0, lines.length - MAX_LINES)
96 const text = `${lines.join('\n')}\n`
97 const file = await logFile($)
98 writing = writing.then(() => $.fs.write(file, text)).catch(() => {})
99 await writing
100 } catch {
101 // Logging failed: carry on.
102 }
103}
104
105/** True when a drawing shows nothing (an empty box or blank text): such a tree only adds a blank row. */
106function isBlank(n: unknown): boolean {
107 if (n === null || n === undefined || n === false || n === true) return true
108 if (typeof n === 'string') return n.trim() === ''
109 if (typeof n === 'number') return false
110 if (Array.isArray(n)) return n.every(isBlank)
111 const el = n as { type?: unknown; children?: unknown[] }
112 if (el.type === 'Box' || el.type === 'Text') return (el.children ?? []).every(isBlank)
113 return false
114}
115
116/**
117 * Claude Code's temp folder. Windows: `<base>/claude`, the base being CLAUDE_CODE_TMPDIR, else TEMP. macOS and Linux:
118 * `<base>/claude-<uid>`, the base being CLAUDE_CODE_TMPDIR, else /tmp (not TMPDIR).
119 */
120async function tempRoot($: EngineInterface): Promise<string | undefined> {
121 const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
122 if (await onWindows($)) {
123 const base = fromEnv ?? (await $.env.get('TEMP')) ?? (await $.env.get('TMP'))
124 return base === undefined ? undefined : `${slashes(base)}/claude`
125 }
126 const uid = await $.process.run(['id', '-u']).then(
127 ran => ran.stdout.trim(),
128 () => '',
129 )
130 return uid === '' ? undefined : `${slashes(fromEnv ?? '/tmp')}/claude-${uid}`
131}
132
133/**
134 * Claude Code keeps each paste as `<temp folder>/<project>/<session>/images/<n>.png`. The project folder is named after
135 * a working directory that may since have moved, so it is found by the session id instead of rebuilt.
136 */
137async function imagesDir($: EngineInterface): Promise<string | undefined> {
138 const sessionId = await $.session.id()
139 if (found?.sessionId === sessionId) return found.dir
140 tmpRoot ??= await tempRoot($)
141 if (tmpRoot === undefined) return undefined
142 const entries = await $.fs.list(tmpRoot).catch(() => [])
143 for (const entry of entries) {
144 const dir = `${tmpRoot}/${entry.name}/${sessionId}/images`
145 if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
146 found = { sessionId, dir }
147 return dir
148 }
149 }
150 return undefined
151}
152
153/** The file of paste n: `<n>.png`, else `<n>.<another extension>` (a dropped JPEG, say). */
154async function fileFor($: EngineInterface, dir: string, n: number): Promise<string | undefined> {
155 const png = `${dir}/${n}.png`
156 if (await $.fs.exists(png)) return png
157 const entries = await $.fs.list(dir).catch(() => [])
158 const other = entries.find(entry => entry.kind === 'file' && entry.name.startsWith(`${n}.`))
159 return other === undefined ? undefined : `${dir}/${other.name}`
160}
161
162/**
163 * A PNG copy of a paste that is not one (JPEG, HEIC, WebP), since a terminal draws only PNG from a file and the decoder
164 * reads only PNG. Made once with macOS's sips, beside the session's images folder. Windows has no sips: undefined.
165 */
166async function pngCopy($: EngineInterface, dir: string, n: number, source: string): Promise<string | undefined> {
167 if (await onWindows($)) return undefined
168 const out = `${dir.replace(/\/images$/, '')}/image-open-${n}.png`
169 if (await $.fs.exists(out)) return out
170 const { exitCode } = await $.process
171 .run(['sips', '-s', 'format', 'png', '-Z', '800', source, '--out', out])
172 .catch(() => ({ exitCode: 1 }))
173 return exitCode === 0 ? out : undefined
174}
175
176/** A file's bytes, or why they could not be read (over the 4 MiB read limit, or held open). */
177async function bytesOf($: EngineInterface, path: string): Promise<Uint8Array | string> {
178 try {
179 return Uint8Array.fromBase64((await $.fs.read(path, { as: 'bytes' })).base64)
180 } catch (err) {
181 return String(err).slice(0, 120)
182 }
183}
184
185/**
186 * Look for paste n's file. Found: read its size and make what its tile draws (the PNG for pixels, or a decoded
187 * thumbnail for half blocks), then fill its entry. Not yet: count the poll, and after FIND_TRIES polls say there is none.
188 */
189async function find($: EngineInterface, n: number): Promise<void> {
190 const started = await $.clock.now()
191 const dir = await imagesDir($)
192 const path = dir === undefined ? undefined : await fileFor($, dir, n)
193 const isPng = path !== undefined && /\.png$/i.test(path)
194 const got = isPng ? await bytesOf($, path) : undefined
195 const bytes = got instanceof Uint8Array ? got : undefined
196 const size = bytes === undefined ? null : pngSize(bytes)
197 // A PNG no longer than its header may still be being written: wait for it as for a missing file.
198 const isPartial = bytes !== undefined && size === null && bytes.length <= 32
199
200 if (dir === undefined || path === undefined || isPartial) {
201 const k = (tries.get(n) ?? 0) + 1
202 tries.set(n, k)
203 if (k < FIND_TRIES) return
204 tries.delete(n)
205 await update($, images, list => list.map(t => (t.n === n ? { n, failed: 'no file' } : t)))
206 const where = dir ?? `${tmpRoot ?? 'the temp folder'} (no folder for this session)`
207 await record($, 'failed', `#${n} no file for it in ${where} after ${k} looks`)
208 $.ui.toast(`image-open: Claude Code's copy of [Image #${n}] was not found`)
209 return
210 }
211
212 tries.delete(n)
213 const entry: Entry = { n, path }
214 if (size !== null) {
215 entry.w = size.width
216 entry.h = size.height
217 }
218 let detail: string
219 if (!isThumbs) {
220 detail = 'thumbnails off'
221 } else if (isPng && bytes === undefined && !(await canDrawPictures($))) {
222 entry.note = 'no preview'
223 detail = `could not read it: ${got}`
224 } else {
225 const png = isPng ? path : await pngCopy($, dir, n, path)
226 if (png === undefined) {
227 entry.note = 'no preview'
228 detail = 'not a PNG, no thumbnail'
229 } else if (await canDrawPictures($)) {
230 // The terminal reads the file itself, so a PNG over the 4 MiB read limit still draws (in a default shape).
231 entry.png = png
232 detail = png === path ? 'drawn as pixels' : `converted to ${png}, drawn as pixels`
233 if (png !== path) {
234 const copy = await bytesOf($, png)
235 const copySize = copy instanceof Uint8Array ? pngSize(copy) : null
236 if (copySize !== null) {
237 entry.w = copySize.width
238 entry.h = copySize.height
239 }
240 }
241 } else {
242 const source = png === path ? bytes : await bytesOf($, png)
243 const thumb = source instanceof Uint8Array ? decodeThumb(source) : null
244 if (source instanceof Uint8Array && png !== path) {
245 const copySize = pngSize(source)
246 if (copySize !== null) {
247 entry.w = copySize.width
248 entry.h = copySize.height
249 }
250 }
251 if (thumb !== null) {
252 entry.thumb = { width: thumb.width, height: thumb.height, rgb: thumb.rgb.toBase64() }
253 detail = `thumb ${thumb.width}x${thumb.height}${png === path ? '' : ` from ${png}`}`
254 } else {
255 entry.note = 'no preview'
256 detail = typeof source === 'string' ? `could not read ${png}: ${source}` : 'could not decode it'
257 }
258 }
259 }
260 await update($, images, list => list.map(t => (t.n === n ? entry : t)))
261 const ms = (await $.clock.now()) - started
262 await record($, 'found', `#${n} ${entry.w ?? '?'}x${entry.h ?? '?'}, ${detail}, ${ms} ms, ${path}`)
263}
264
265/** The [open] button: hand image n's file to the system, which opens it in the default viewer. */
266async function openFull($: EngineInterface, n: number, path: string): Promise<void> {
267 try {
268 const started = await $.clock.now()
269 // explorer.exe wants backslashes and usually exits with 1 even when the viewer opened; macOS has `open`.
270 const windows = await onWindows($)
271 const argv = windows ? ['explorer.exe', path.replace(/\//g, '\\')] : ['open', path]
272 const ran = await $.process.run(argv, { timeoutMs: 10_000 })
273 const ms = (await $.clock.now()) - started
274 await record($, 'open', `#${n} ${path} (${windows ? 'explorer' : 'open'} exit ${ran.exitCode}, ${ms} ms)`)
275 } catch (err) {
276 await record($, 'error', `open #${n}: ${String(err).slice(0, 160)}`)
277 $.ui.toast(`image-open: could not open [Image #${n}]`)
278 }
279}
280
281/** Compare the box with the entries: add new tokens, drop the ones that left, and look for every file still missing. */
282async function sync($: EngineInterface): Promise<void> {
283 if (isSyncing) return
284 isSyncing = true
285 try {
286 const { text } = await $.prompt.read()
287 const inBox = imageNumbers(text)
288 const list = await read($, images)
289 const have = new Set(list.map(t => t.n))
290 const fresh = inBox.filter(n => !have.has(n))
291 const gone = list.filter(t => !inBox.includes(t.n))
292 if (fresh.length > 0 || gone.length > 0) {
293 await update($, images, cur =>
294 [...cur.filter(t => inBox.includes(t.n)), ...fresh.map(n => ({ n }))].sort((a, b) => a.n - b.n),
295 )
296 }
297 for (const t of gone) {
298 tries.delete(t.n)
299 await record($, 'gone', `[Image #${t.n}] left the box`)
300 }
301 for (const n of fresh) await record($, 'token', `[Image #${n}] in the box; box=${show(text)}`)
302 // Every token still without its file; and, once the option is turned on, every entry found while it was off.
303 const waiting = (await read($, images)).filter(
304 t =>
305 t.failed === undefined &&
306 (t.path === undefined || (isThumbs && t.thumb === undefined && t.png === undefined && t.note === undefined)),
307 )
308 for (const t of waiting) await find($, t.n)
309 } catch {
310 // No box mounted yet, or a dialog holds it: try again next period.
311 } finally {
312 isSyncing = false
313 }
314}
315
316export const register: Register = (on, options) => {
317 isThumbs = options.thumbnails !== false
318
319 on('session.start', async ($, e, next) => {
320 const started = await next(e)
321 await seedLog($)
322 const tiles = !isThumbs ? 'off' : (await canDrawPictures($)) ? 'pixels' : 'half blocks'
323 await record($, 'start', `image-open armed: polling the box every ${POLL_MS} ms for [Image #N]; tiles ${tiles}`)
324 $.clock.every(POLL_MS, () => {
325 void sync($)
326 })
327 return started
328 })
329
330 on('prompt.submit', async ($, e, next) => {
331 const attached = (e.attachments ?? []).map(a => `${a.type}/${a.mediaType ?? '?'}`).join(', ')
332 if (attached !== '') await record($, 'submit', `sent with attachments: ${attached}`)
333 return next(e)
334 }).catch(($, e, next) => next(e))
335
336 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337 const list = await read($, images)
338 if (list.length === 0 || e.props.hasSurvey) return next(e)
339
340 const { Box, Text, Button } = $.ui.resolve(e)
341 // Tiles on the terminal only: Raster and Image are terminal elements, and the desktop app shows pastes itself.
342 const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : undefined
343 const Image = e.surface === 'terminal' ? $.ui.resolve(e).Image : undefined
344 const sizes = list.map(t => ({ n: t.n, size: t.w !== undefined && t.h !== undefined ? { width: t.w, height: t.h } : null }))
345 // Too small a band for one tile: the text row, so every [open] button stays in reach.
346 const cells = Raster !== undefined && isThumbs ? fitRow(sizes, e.props.maxRows, e.props.bodyColumns) : []
347 const hidden = list.length - cells.length
348 const beneath = await next(e)
349
350 return (
351 <Box flexDirection="column">
352 {Raster !== undefined && Image !== undefined && cells.length > 0 ? (
353 <Box key="io-row" flexDirection="row" columnGap={1}>
354 {list.slice(0, cells.length).map((t, i) => {
355 const { columns, rows } = cells[i]!
356 const path = t.path
357 const thumb = t.thumb
358 const caption = t.failed ?? t.note ?? (path === undefined ? 'reading…' : 'no preview')
359 return (
360 <Box key={`io-${t.n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
361 {thumb !== undefined ? (
362 <Raster
363 key={`io-pic-${t.n}`}
364 columns={columns}
365 rows={rows}
366 cells={blockCells({ width: thumb.width, height: thumb.height, rgb: Uint8Array.fromBase64(thumb.rgb) }, columns, rows)}
367 />
368 ) : t.png !== undefined ? (
369 <Image
370 key={`io-pic-${t.n}`}
371 source={{ file: t.png, format: 'png' }}
372 columns={columns}
373 rows={rows}
374 alt={`[Image #${t.n}]`}
375 />
376 ) : (
377 <Box width={columns} height={rows} alignItems="center" justifyContent="center">
378 <Text dimColor wrap="truncate">
379 {caption}
380 </Text>
381 </Box>
382 )}
383 <Box flexDirection="row" columnGap={1}>
384 <Text dimColor>#{t.n}</Text>
385 {path !== undefined && <Button key={`io-open-${t.n}`} label="open" onPress={() => openFull($, t.n, path)} />}
386 </Box>
387 </Box>
388 )
389 })}
390 {hidden > 0 && <Text dimColor>+{hidden}</Text>}
391 </Box>
392 ) : (
393 <Box key="io-row" flexDirection="row" flexWrap="wrap" columnGap={3}>
394 {list.map(t => {
395 const path = t.path
396 const label =
397 t.failed !== undefined
398 ? `Image #${t.n} ${t.failed}`
399 : t.w !== undefined
400 ? `Image #${t.n} ${t.w}×${t.h}`
401 : path !== undefined
402 ? `Image #${t.n} ${t.note ?? ''}`.trimEnd()
403 : `Image #${t.n} reading…`
404 return (
405 <Box key={`io-${t.n}`} flexDirection="row" columnGap={1}>
406 <Text dimColor>{label}</Text>
407 {path !== undefined && <Button key={`io-open-${t.n}`} label="open" onPress={() => openFull($, t.n, path)} />}
408 </Box>
409 )
410 })}
411 </Box>
412 )}
413 {!isBlank(beneath) && beneath}
414 </Box>
415 )
416 })
417}
418hooks/layout.ts 144 lines1// Tile sizing and half-block cells, adapted from claude-image-view by Jarrod Watts (hooks/layout.ts, MIT; see
2// THIRD-PARTY-NOTICES.md), with his drawsPictures rule. Changes: bigger tiles, a label row wide enough for the
3// [ open ] button, PNG size from bytes, the override variable renamed.
4// Pure helpers only: no `$` crosses into this file.
5
6export type Size = { width: number; height: number }
7export type Cells = { columns: number; rows: number }
8export type Tile = { n: number; size: Size | null }
9
10// The picture's box. A cell holds two pixels, one above the other, so 8 rows by 48 columns is 48 x 16 pixels.
11const TILE_ROWS = 8
12const MAX_COLUMNS = 48
13const MIN_COLUMNS = 4
14// A terminal cell is about twice as tall as it is wide.
15const CELL_ASPECT = 2
16// Used when the size is unknown (no file yet, or not a PNG).
17const FALLBACK: Size = { width: 16, height: 10 }
18// Each tile adds a border on every side and the label row (#N [ open ]) under the picture.
19const TILE_CHROME_ROWS = 3
20const TILE_CHROME_COLUMNS = 2
21const GAP = 1
22// The terminal draws a Button as `[ label ]`.
23const OPEN_BUTTON = '[ open ]'.length
24
25/** The distinct image numbers the prompt box holds, in the order they first appear. */
26export function imageNumbers(text: string): number[] {
27 const seen = new Set<number>()
28 for (const match of text.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
29 return [...seen]
30}
31
32/** Width and height from a PNG's IHDR chunk, or null when the bytes are not (yet) a whole PNG header. */
33export function pngSize(bytes: Uint8Array): Size | null {
34 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
35 if (bytes.length < 24 || signature.some((byte, i) => bytes[i] !== byte)) return null
36 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
37 const width = view.getUint32(16)
38 const height = view.getUint32(20)
39 return width > 0 && height > 0 ? { width, height } : null
40}
41
42/** How wide the label row under picture n is: `#N`, a space, the button. */
43export function labelColumns(n: number): number {
44 return `#${n}`.length + 1 + OPEN_BUTTON
45}
46
47/** A picture box at most `tileRows` tall that keeps the picture's aspect ratio. */
48export function fitCells(size: Size | null, tileRows = TILE_ROWS): Cells {
49 const { width, height } = size ?? FALLBACK
50 let rows = tileRows
51 let columns = Math.round((rows * CELL_ASPECT * width) / height)
52 if (columns > MAX_COLUMNS) {
53 columns = MAX_COLUMNS
54 rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
55 }
56 return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
57}
58
59/**
60 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls: the tallest tiles whose chrome
61 * fits in `maxRows` and whose total width fits in `bodyColumns`. When even one-row tiles are too wide, only the first
62 * ones that fit beside a `+N` label for the rest; none when the band is too small for one tile.
63 */
64export function fitRow(tiles: readonly Tile[], maxRows: number, bodyColumns: number): Cells[] {
65 if (maxRows < TILE_CHROME_ROWS + 1) return []
66 const tallest = Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS)
67 for (let tileRows = tallest; tileRows > 1; tileRows--) {
68 const cells = tiles.map(t => fitCells(t.size, tileRows))
69 if (rowWidth(tiles, cells, 0) <= bodyColumns) return cells
70 }
71 const cells = tiles.map(t => fitCells(t.size, 1))
72 let count = cells.length
73 while (count > 0 && rowWidth(tiles.slice(0, count), cells.slice(0, count), cells.length - count) > bodyColumns) count--
74 return cells.slice(0, count)
75}
76
77/** A tile is as wide as its picture or its label row, whichever is wider, plus the border. */
78function rowWidth(tiles: readonly Tile[], cells: readonly Cells[], hidden: number): number {
79 const inner = (c: Cells, i: number) => Math.max(c.columns, labelColumns(tiles[i]!.n))
80 const total = cells.reduce((sum, c, i) => sum + inner(c, i) + TILE_CHROME_COLUMNS, 0) + GAP * Math.max(0, cells.length - 1)
81 return hidden > 0 ? total + GAP + `+${hidden}`.length : total
82}
83
84type Env = Readonly<Record<string, string | undefined>>
85
86/**
87 * Whether Claude Code will draw `Image` as real pixels. It decides that itself and does not tell mods, so this mirrors
88 * its rule as far as the environment shows it: always when CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set; otherwise never
89 * inside tmux or screen or in a background session, and only in kitty or Ghostty (it goes by the terminal's name).
90 * Windows Terminal, Terminal.app and iTerm2 get half blocks. `IMAGE_OPEN_RENDERER` overrides the guess: `image` for
91 * pixels, `blocks` for half blocks.
92 */
93export function drawsPictures(env: Env): boolean {
94 const forced = env.IMAGE_OPEN_RENDERER
95 if (forced === 'image' || forced === 'blocks') return forced === 'image'
96 if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) return true
97 if (env.TMUX || env.STY || env.CLAUDE_CODE_SESSION_KIND === 'bg') return false
98 return (
99 Boolean(env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR) ||
100 /kitty|ghostty/i.test(env.TERM ?? '') ||
101 /^ghostty$/i.test(env.TERM_PROGRAM ?? '')
102 )
103}
104
105/**
106 * The cells of a `Raster` that draws `rgb` (a `width` x `height` thumbnail) in `columns` x `rows` cells, base64. Each
107 * cell is an upper half block, so it holds two pixels: the top as its foreground and the bottom as its background.
108 * Every pixel keeps its own averaged colour: no palette, no snapping.
109 */
110export function blockCells(thumb: { width: number; height: number; rgb: Uint8Array }, columns: number, rows: number): string {
111 const pixelRows = rows * 2
112 const average = (px: number, py: number): number => {
113 // The box of source pixels under output pixel (px, py), at least one pixel wide and tall.
114 const x0 = Math.floor((px * thumb.width) / columns)
115 const x1 = Math.max(x0 + 1, Math.floor(((px + 1) * thumb.width) / columns))
116 const y0 = Math.floor((py * thumb.height) / pixelRows)
117 const y1 = Math.max(y0 + 1, Math.floor(((py + 1) * thumb.height) / pixelRows))
118 let r = 0
119 let g = 0
120 let b = 0
121 let n = 0
122 for (let y = y0; y < y1 && y < thumb.height; y++) {
123 for (let x = x0; x < x1 && x < thumb.width; x++, n++) {
124 const at = (y * thumb.width + x) * 3
125 r += thumb.rgb[at]!
126 g += thumb.rgb[at + 1]!
127 b += thumb.rgb[at + 2]!
128 }
129 }
130 n = n || 1
131 return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
132 }
133 const words = new Uint32Array(columns * rows * 3)
134 for (let row = 0; row < rows; row++) {
135 for (let column = 0; column < columns; column++) {
136 const at = (row * columns + column) * 3
137 words[at] = 0x2580
138 words[at + 1] = average(column, row * 2)
139 words[at + 2] = average(column, row * 2 + 1)
140 }
141 }
142 return new Uint8Array(words.buffer).toBase64()
143}
144hooks/png.ts 293 lines1// Copied from claude-image-view by Jarrod Watts (hooks/png.ts, MIT; see THIRD-PARTY-NOTICES.md). Only change: three
2// type annotations so it passes this vault's stricter tsc (TypeScript 7, noUncheckedIndexedAccess).
3// https://github.com/jarrodwatts/claude-image-view
4//
5// A small PNG decoder for terminals that can't draw pictures. A mod has no zlib, so this
6// carries its own inflate. It box-averages the picture down to a thumbnail as it unfilters,
7// so a screenshot never has to sit in memory as RGBA.
8
9export type Thumb = { width: number; height: number; rgb: Uint8Array }
10
11// The longest side of the stored thumbnail, in pixels: more than any tile can show.
12const THUMB_SIDE = 96
13// What transparent pixels are composited over (the dark of most terminal themes).
14const BACKDROP = 0x1e
15// Refuse a picture whose raw scanlines would be bigger than this (an Adam7-free 8k x 8k RGBA).
16const MAX_RAW_BYTES = 256 * 1024 * 1024
17
18const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
19const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
20const DISTANCE_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
21const DISTANCE_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
22const CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
23
24type Huffman = { table: Uint32Array; bits: number }
25
26/** A lookup table indexed by the next `bits` input bits: (code length << 16) | symbol. */
27function huffman(lengths: ArrayLike<number>): Huffman {
28 let bits = 0
29 const count = new Array<number>(16).fill(0)
30 for (let i = 0; i < lengths.length; i++) {
31 count[lengths[i]!]!++
32 if (lengths[i]! > bits) bits = lengths[i]!
33 }
34 count[0] = 0
35 const next = new Array<number>(16).fill(0)
36 for (let len = 1, code = 0; len < 16; len++) {
37 code = (code + count[len - 1]!) << 1
38 next[len] = code
39 }
40 const size = 1 << Math.max(bits, 1)
41 const table = new Uint32Array(size)
42 for (let symbol = 0; symbol < lengths.length; symbol++) {
43 const len = lengths[symbol]!
44 if (len === 0) continue
45 let code = next[len]!++
46 let reversed = 0
47 for (let i = 0; i < len; i++, code >>= 1) reversed = (reversed << 1) | (code & 1)
48 for (let at = reversed; at < size; at += 1 << len) table[at] = (len << 16) | symbol
49 }
50 return { table, bits: Math.max(bits, 1) }
51}
52
53let fixedLiterals: Huffman | undefined
54let fixedDistances: Huffman | undefined
55
56/** Inflate a zlib stream into exactly `size` bytes; null when it is malformed or short. */
57export function inflate(input: Uint8Array, size: number): Uint8Array | null {
58 if (input.length < 2 || (input[0]! & 0x0f) !== 8) return null
59 const out = new Uint8Array(size)
60 let pos = 2
61 let written = 0
62 let buffer = 0
63 let held = 0
64
65 const need = (n: number) => {
66 while (held < n) {
67 buffer |= (pos < input.length ? input[pos]! : 0) << held
68 pos++
69 held += 8
70 }
71 }
72 const take = (n: number) => {
73 if (n === 0) return 0
74 need(n)
75 const value = buffer & ((1 << n) - 1)
76 buffer >>>= n
77 held -= n
78 return value
79 }
80 const decode = ({ table, bits }: Huffman) => {
81 need(bits)
82 const entry = table[buffer & ((1 << bits) - 1)]!
83 const len = entry >>> 16
84 if (len === 0) return -1
85 buffer >>>= len
86 held -= len
87 return entry & 0xffff
88 }
89
90 let last = false
91 while (!last) {
92 if (pos > input.length + 4) return null
93 last = take(1) === 1
94 const type = take(2)
95 if (type === 0) {
96 take(held & 7)
97 const len = take(16)
98 take(16)
99 for (let i = 0; i < len && written < size; i++) out[written++] = take(8)
100 continue
101 }
102 let literals: Huffman
103 let distances: Huffman
104 if (type === 1) {
105 if (!fixedLiterals || !fixedDistances) {
106 const lengths = new Uint8Array(288)
107 lengths.fill(8, 0, 144).fill(9, 144, 256).fill(7, 256, 280).fill(8, 280, 288)
108 fixedLiterals = huffman(lengths)
109 fixedDistances = huffman(new Uint8Array(30).fill(5))
110 }
111 literals = fixedLiterals
112 distances = fixedDistances
113 } else if (type === 2) {
114 const nLiterals = take(5) + 257
115 const nDistances = take(5) + 1
116 const nCodes = take(4) + 4
117 const codeLengths = new Uint8Array(19)
118 for (let i = 0; i < nCodes; i++) codeLengths[CODE_LENGTH_ORDER[i]!] = take(3)
119 const codes = huffman(codeLengths)
120 const lengths = new Uint8Array(nLiterals + nDistances)
121 for (let i = 0; i < lengths.length; ) {
122 const symbol = decode(codes)
123 if (symbol < 0) return null
124 if (symbol < 16) {
125 lengths[i++] = symbol
126 continue
127 }
128 const repeat = symbol === 16 ? 3 + take(2) : symbol === 17 ? 3 + take(3) : 11 + take(7)
129 const value = symbol === 16 ? lengths[i - 1]! : 0
130 if (symbol === 16 && i === 0) return null
131 for (let r = 0; r < repeat && i < lengths.length; r++) lengths[i++] = value
132 }
133 literals = huffman(lengths.subarray(0, nLiterals))
134 distances = huffman(lengths.subarray(nLiterals))
135 } else {
136 return null
137 }
138
139 for (;;) {
140 const symbol = decode(literals)
141 if (symbol < 0) return null
142 if (symbol < 256) {
143 if (written >= size) return null
144 out[written++] = symbol
145 continue
146 }
147 if (symbol === 256) break
148 const lengthIndex = symbol - 257
149 if (lengthIndex >= 29) return null
150 const length = LENGTH_BASE[lengthIndex]! + take(LENGTH_EXTRA[lengthIndex]!)
151 const distanceSymbol = decode(distances)
152 if (distanceSymbol < 0 || distanceSymbol >= 30) return null
153 const distance = DISTANCE_BASE[distanceSymbol]! + take(DISTANCE_EXTRA[distanceSymbol]!)
154 if (distance > written || written + length > size) return null
155 for (let i = 0; i < length; i++, written++) out[written] = out[written - distance]!
156 }
157 }
158 return written === size ? out : null
159}
160
161const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
162// Samples per pixel by PNG color type: gray, -, RGB, palette, gray + alpha, -, RGBA.
163const CHANNELS = [1, 0, 3, 1, 2, 0, 4]
164
165/**
166 * Decode a PNG into a small RGB thumbnail (longest side at most THUMB_SIDE), or null when it
167 * isn't a PNG, is interlaced, or is malformed. Transparent pixels are composited over a dark backdrop.
168 */
169export function decodeThumb(png: Uint8Array): Thumb | null {
170 if (png.length < 33 || SIGNATURE.some((byte, i) => png[i] !== byte)) return null
171 const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
172
173 let width = 0
174 let height = 0
175 let depth = 0
176 let colorType = 0
177 let palette: Uint8Array = new Uint8Array(0)
178 let paletteAlpha: Uint8Array = new Uint8Array(0)
179 const data: Uint8Array[] = []
180 for (let at = 8; at + 8 <= png.length; ) {
181 const length = view.getUint32(at)
182 const name = String.fromCharCode(png[at + 4]!, png[at + 5]!, png[at + 6]!, png[at + 7]!)
183 const body = png.subarray(at + 8, at + 8 + length)
184 if (body.length < length) return null
185 if (name === 'IHDR' && length >= 13) {
186 width = view.getUint32(at + 8)
187 height = view.getUint32(at + 12)
188 depth = body[8]!
189 colorType = body[9]!
190 if (body[10] !== 0 || body[11] !== 0 || body[12] !== 0) return null // not deflate, or interlaced
191 } else if (name === 'PLTE') palette = body
192 else if (name === 'tRNS') paletteAlpha = body
193 else if (name === 'IDAT') data.push(body)
194 else if (name === 'IEND') break
195 at += 12 + length
196 }
197 const channels = CHANNELS[colorType] ?? 0
198 if (width === 0 || height === 0 || channels === 0 || ![1, 2, 4, 8, 16].includes(depth)) return null
199 if (colorType === 3 && (palette.length === 0 || depth === 16)) return null
200
201 const bitsPerPixel = channels * depth
202 const stride = Math.ceil((width * bitsPerPixel) / 8)
203 const step = Math.max(1, bitsPerPixel >> 3)
204 if ((stride + 1) * height > MAX_RAW_BYTES) return null
205
206 const joined = new Uint8Array(data.reduce((sum, chunk) => sum + chunk.length, 0))
207 for (let i = 0, offset = 0; i < data.length; offset += data[i]!.length, i++) joined.set(data[i]!, offset)
208 const raw = inflate(joined, (stride + 1) * height)
209 if (!raw) return null
210
211 const scale = Math.min(1, THUMB_SIDE / Math.max(width, height))
212 const tw = Math.max(1, Math.round(width * scale))
213 const th = Math.max(1, Math.round(height * scale))
214 const sums = new Float64Array(tw * th * 3)
215 const counts = new Uint32Array(tw * th)
216 const columnOf = new Uint16Array(width)
217 for (let x = 0; x < width; x++) columnOf[x] = Math.min(tw - 1, Math.floor((x * tw) / width))
218
219 const max = (1 << Math.min(depth, 8)) - 1
220 const unit = depth === 16 ? 1 : 255 / max
221 let prior = new Uint8Array(stride)
222 let line = new Uint8Array(stride)
223 const sample = (x: number, channel: number) => {
224 if (depth === 8) return line[x * channels + channel]!
225 if (depth === 16) return line[(x * channels + channel) * 2]!
226 const bit = (x * channels + channel) * depth
227 return (line[bit >> 3]! >> (8 - depth - (bit & 7))) & max
228 }
229
230 for (let y = 0; y < height; y++) {
231 const filter = raw[y * (stride + 1)]!
232 const source = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
233 for (let i = 0; i < stride; i++) {
234 const left = i >= step ? line[i - step]! : 0
235 const up = prior[i]!
236 const upLeft = i >= step ? prior[i - step]! : 0
237 let predicted = 0
238 if (filter === 1) predicted = left
239 else if (filter === 2) predicted = up
240 else if (filter === 3) predicted = (left + up) >> 1
241 else if (filter === 4) {
242 const p = left + up - upLeft
243 const pa = Math.abs(p - left)
244 const pb = Math.abs(p - up)
245 const pc = Math.abs(p - upLeft)
246 predicted = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft
247 } else if (filter !== 0) return null
248 line[i] = (source[i]! + predicted) & 0xff
249 }
250
251 const row = Math.min(th - 1, Math.floor((y * th) / height))
252 for (let x = 0; x < width; x++) {
253 let r: number
254 let g: number
255 let b: number
256 let a = 255
257 if (colorType === 2 || colorType === 6) {
258 r = sample(x, 0)
259 g = sample(x, 1)
260 b = sample(x, 2)
261 if (colorType === 6) a = sample(x, 3)
262 } else if (colorType === 3) {
263 const index = sample(x, 0)
264 r = palette[index * 3] ?? 0
265 g = palette[index * 3 + 1] ?? 0
266 b = palette[index * 3 + 2] ?? 0
267 a = paletteAlpha[index] ?? 255
268 } else {
269 r = g = b = Math.round(sample(x, 0) * unit)
270 if (colorType === 4) a = sample(x, 1)
271 }
272 if (a < 255) {
273 r = (r * a + BACKDROP * (255 - a)) / 255
274 g = (g * a + BACKDROP * (255 - a)) / 255
275 b = (b * a + BACKDROP * (255 - a)) / 255
276 }
277 const cell = row * tw + columnOf[x]!
278 sums[cell * 3] = sums[cell * 3]! + r
279 sums[cell * 3 + 1] = sums[cell * 3 + 1]! + g
280 sums[cell * 3 + 2] = sums[cell * 3 + 2]! + b
281 counts[cell]!++
282 }
283 ;[prior, line] = [line, prior]
284 }
285
286 const rgb = new Uint8Array(tw * th * 3)
287 for (let cell = 0; cell < tw * th; cell++) {
288 const n = counts[cell] || 1
289 for (let c = 0; c < 3; c++) rgb[cell * 3 + c] = Math.round(sums[cell * 3 + c]! / n)
290 }
291 return { width: tw, height: th, rgb }
292}
293types/index.d.ts 30 lines1/**
2 * One pasted image, keyed by the number in its `[Image #N]` token.
3 * The number counts the whole session, not the prompt box, so it is the only safe key.
4 */
5export type Entry = {
6 n: number
7 /** Size of the picture, in pixels (PNG only). */
8 w?: number
9 h?: number
10 /** Claude Code's own copy of the paste, forward slashes. Present means the [open] button works. */
11 path?: string
12 /** Half-block tiles: the small decoded copy the tile is drawn from. `rgb` is base64, 3 bytes a pixel, row by row. */
13 thumb?: { width: number; height: number; rgb: string }
14 /** Real-pixel tiles (kitty, Ghostty): the PNG the terminal draws, the paste itself or a converted copy of it. */
15 png?: string
16 /** Why a found file has no thumbnail, in a few words (not a PNG and not converted, over the 4 MiB read limit, could not decode). */
17 note?: string
18 /** Why there is no file at all, in a few words. The log has the detail. */
19 failed?: string
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'image-open': {
25 /** One entry per `[Image #N]` token now in the prompt box, oldest first. */
26 images: Entry[]
27 }
28 }
29}
30