SLOPSHOPPER

image-open

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…

newbandtoastpromptprocesstimer
v0.2.0MITupdated 2026-10-18richardmuucyi/rmahwj-mods/image-open
A shopper browsing a rack in a slop shop
README

image-open

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.

Source 4 files
hooks/register.tsx 418 lines
1import { 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}
418
hooks/layout.ts 144 lines
1// 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}
144
hooks/png.ts 293 lines
1// 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}
293
types/index.d.ts 30 lines
1/**
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