SLOPSHOPPER

image-preview

Thumbnails of pasted images above the prompt and on the sent message, and no duplicate pastes in one prompt

newbandrowstoastprocesstimer
v0.1.0MITupdated 2026-10-26lpareja99/claude-mods/image-preview
A shopper browsing a rack in a slop shop
README

image-preview

A Claude Code mod for pasted images. It does three things:

  • Thumbnails above the prompt as soon as you paste, instead of a bare [Image #1] tag.
  • Thumbnails on the sent message in the transcript, which stay after the row above the prompt clears on send.
  • No duplicate pastes: pasting the same picture twice in one prompt keeps only the first tag and shows a toast.

Real pictures in Ghostty and kitty; colored half blocks in other terminals. Non-PNG pastes are converted with sips (macOS).

Install

/plugin install image-preview --marketplace lpareja99/claude-mods
/reload-plugins

Credits

The prompt thumbnails, PNG decoding and layout code are adapted from jarrodwatts/claude-image-view (MIT, see LICENSE).

Source 4 files
hooks/register.tsx 293 lines
1// Thumbnails of pasted images above the prompt and on the sent message, and no duplicate
2// pastes in one draft. The prompt band is adapted from jarrodwatts/claude-image-view (MIT).
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { PastedImage } from '../types'
7import { blockCells, drawsPictures, fitCells, fitRow, imageNumbers, pngSize } from './layout'
8import type { Size } from './layout'
9import { decodeThumb } from './png'
10import type { Thumb } from './png'
11
12// Pasting raises no prompt.edit (the tag only shows up on the next keystroke), so the draft is polled.
13const POLL_MS = 200
14const SENT_TILE_ROWS = 6
15
16const images = atom({ plugin: 'image-preview', key: 'images' } as const, [] as PastedImage[])
17
18let tmpRoot: string | undefined
19let found: { sessionId: string; dir: string } | undefined
20// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
21// while a drawn image's file is still missing, so the next poll looks again.
22let shownKey: string | undefined
23// The list last written, so a poll that finds the same tiles doesn't redraw the band.
24let written: string | undefined
25let isChecking = false
26const sizes = new Map<string, Size | null>()
27// Files whose first bytes aren't a PNG's: they never will be, so they aren't read again.
28const notPng = new Set<string>()
29// Source files sips couldn't convert, so a broken paste isn't retried on every poll.
30const unconvertible = new Set<string>()
31// Small decoded copies for terminals that can't draw pictures, which get colored blocks instead.
32const thumbs = new Map<string, Thumb | null>()
33const contentKeys = new Map<string, string>()
34let pictures: boolean | undefined
35
36// Claude Code doesn't tell mods whether its Image draws a picture, so go by the environment it decides from.
37async function canDrawPictures($: EngineInterface): Promise<boolean> {
38  if (pictures === undefined) {
39    pictures = drawsPictures({
40      TERM: await $.env.get('TERM'),
41      TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
42      TMUX: await $.env.get('TMUX'),
43      STY: await $.env.get('STY'),
44      KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
45      GHOSTTY_RESOURCES_DIR: await $.env.get('GHOSTTY_RESOURCES_DIR'),
46      CLAUDE_CODE_FORCE_TERMINAL_IMAGES: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
47      CLAUDE_CODE_SESSION_KIND: await $.env.get('CLAUDE_CODE_SESSION_KIND'),
48      CLAUDE_IMAGE_VIEW_RENDERER: await $.env.get('CLAUDE_IMAGE_VIEW_RENDERER'),
49    })
50  }
51  return pictures
52}
53
54// Claude Code keeps its temp files in <base>/claude-<uid>, or <base>\claude on Windows.
55async function tempRoot($: EngineInterface): Promise<string | undefined> {
56  const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
57  if ((await $.env.get('OS')) === 'Windows_NT') {
58    const base = fromEnv ?? (await $.env.get('TEMP'))
59    return base === undefined ? undefined : `${base.replace(/[\\/]+$/, '')}/claude`
60  }
61  const uid = (await $.process.run(['id', '-u'])).stdout.trim()
62  return `${(fromEnv ?? '/tmp').replace(/\/+$/, '')}/claude-${uid}`
63}
64
65// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.<ext>. The project folder
66// is named after a working directory that may since have moved, so find it by the session id.
67async function imagesDir($: EngineInterface): Promise<string | undefined> {
68  const sessionId = await $.session.id()
69  if (found?.sessionId === sessionId) return found.dir
70  tmpRoot ??= await tempRoot($)
71  if (tmpRoot === undefined) return undefined
72  const entries = await $.fs.list(tmpRoot).catch(() => [])
73  for (const entry of entries) {
74    const dir = `${tmpRoot}/${entry.name}/${sessionId}/images`
75    if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
76      found = { sessionId, dir }
77      return dir
78    }
79  }
80  return undefined
81}
82
83// The terminal only decodes PNG, so any other format is converted once (sips, macOS only)
84// to <session>/image-view-<n>.png.
85async function pngFor($: EngineInterface, dir: string, n: number): Promise<string | undefined> {
86  const png = `${dir}/${n}.png`
87  if (await $.fs.exists(png)) return png
88  const out = `${dir.replace(/\/images$/, '')}/image-view-${n}.png`
89  if (await $.fs.exists(out)) return out
90  const entries = await $.fs.list(dir).catch(() => [])
91  const source = entries.find(entry => entry.kind === 'file' && entry.name.startsWith(`${n}.`))
92  if (source === undefined) return undefined
93  const sourcePath = `${dir}/${source.name}`
94  if (unconvertible.has(sourcePath)) return undefined
95  const { exitCode } = await $.process
96    .run(['sips', '-s', 'format', 'png', '-Z', '800', sourcePath, '--out', out])
97    .catch(() => ({ exitCode: 1 }))
98  if (exitCode === 0) return out
99  unconvertible.add(sourcePath)
100  return undefined
101}
102
103async function describe($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
104  const path = dir === undefined ? undefined : await pngFor($, dir, n)
105  if (path === undefined || notPng.has(path)) return { n, path: null, size: null }
106  if (!sizes.has(path)) {
107    const base64 = await $.fs.read(path, { as: 'bytes' }).then(
108      bytes => bytes.base64,
109      () => undefined, // too big to read: still drawable, just without its aspect ratio
110    )
111    const head = base64 === undefined ? undefined : pngSize(base64)
112    if (head === null) {
113      // A file no longer than the 24-byte header may still be being written; a longer one is settled.
114      if (base64!.length > 32) notPng.add(path)
115      return { n, path: null, size: null }
116    }
117    sizes.set(path, head ?? null)
118    if (base64 !== undefined && !(await canDrawPictures($))) thumbs.set(path, decodeThumb(Uint8Array.fromBase64(base64)))
119  }
120  return { n, path, size: sizes.get(path) ?? null }
121}
122
123async function show($: EngineInterface, draft: string) {
124  const numbers = imageNumbers(draft)
125  const key = numbers.join(',')
126  if (key === shownKey) return
127  const dir = numbers.length > 0 ? await imagesDir($) : undefined
128  const list: PastedImage[] = []
129  for (const n of numbers) list.push(await describe($, dir, n))
130  shownKey = list.every(image => image.path !== null) ? key : undefined
131  const json = JSON.stringify(list)
132  if (json === written) return
133  await update($, images, () => list)
134  written = json
135}
136
137// Length plus an FNV-1a hash of the base64: enough to tell one picture from another.
138function contentKey(base64: string): string {
139  let hash = 0x811c9dc5
140  for (let i = 0; i < base64.length; i++) hash = Math.imul(hash ^ base64.charCodeAt(i), 0x01000193)
141  return `${base64.length}:${hash >>> 0}`
142}
143
144async function keyFor($: EngineInterface, dir: string, n: number): Promise<string | undefined> {
145  const id = `${dir}#${n}`
146  const known = contentKeys.get(id)
147  if (known !== undefined) return known
148  const entries = await $.fs.list(dir).catch(() => [])
149  const file = entries.find(entry => entry.kind === 'file' && entry.name.startsWith(`${n}.`))
150  if (file === undefined) return undefined
151  const base64 = await $.fs.read(`${dir}/${file.name}`, { as: 'bytes' }).then(b => b.base64, () => undefined)
152  if (base64 === undefined || base64.length === 0) return undefined // unreadable or still being written: look again
153  const key = contentKey(base64)
154  contentKeys.set(id, key)
155  return key
156}
157
158// Drops every [Image #n] tag whose picture an earlier tag in the draft already shows.
159async function dedupe($: EngineInterface) {
160  const { text } = await $.prompt.read()
161  const tags = [...text.matchAll(/\[Image #(\d+)\]/g)]
162  if (tags.length < 2) return
163  const dir = await imagesDir($)
164  if (dir === undefined) return
165  const seen = new Set<string>()
166  const drop: { start: number; end: number }[] = []
167  for (const tag of tags) {
168    const key = await keyFor($, dir, Number(tag[1]))
169    if (key === undefined) continue
170    if (seen.has(key)) {
171      const start = tag.index!
172      const end = start + tag[0].length
173      // Take the space that separated it from its neighbour along with it.
174      if (start > 0 && text[start - 1] === ' ') drop.push({ start: start - 1, end })
175      else drop.push({ start, end: text[end] === ' ' ? end + 1 : end })
176    } else seen.add(key)
177  }
178  if (drop.length === 0) return
179  // The draft may have changed while the files were read: act only on what was read.
180  if ((await $.prompt.read()).text !== text) return
181  let next = text
182  for (const { start, end } of drop.reverse()) next = next.slice(0, start) + next.slice(end)
183  await $.prompt.fill({ text: next })
184  $.ui.toast(`${drop.length === 1 ? 'Duplicate image' : `${drop.length} duplicate images`} skipped`)
185}
186
187async function check($: EngineInterface) {
188  if (isChecking) return
189  isChecking = true
190  try {
191    await dedupe($)
192    await show($, (await $.prompt.read()).text)
193  } finally {
194    isChecking = false
195  }
196}
197
198export const register: Register = on => {
199  on('session.start', async ($, e, next) => {
200    pictures = undefined
201    $.clock.every(POLL_MS, () => check($))
202    return next(e)
203  })
204
205  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
206    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
207    const list = await read($, images)
208    if (list.length === 0) return next(e)
209
210    const { Box, Image, Raster, Text } = $.ui.resolve(e)
211    const cells = fitRow(list.map(image => image.size), e.props.maxRows, e.props.bodyColumns)
212    if (cells.length === 0) return next(e)
213    const hidden = list.length - cells.length
214    const below = await next(e)
215
216    return (
217      <Box flexDirection="column">
218        <Box flexDirection="row" columnGap={1}>
219          {list.slice(0, cells.length).map((image, i) => {
220            const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
221            return (
222              <Box key={`tile-${image.n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
223                {image.path === null ? (
224                  <Box width={columns} height={rows} alignItems="center" justifyContent="center">
225                    <Text dimColor wrap="truncate">no preview</Text>
226                  </Box>
227                ) : thumbs.get(image.path) ? (
228                  <Raster
229                    key={`blocks-${image.n}`}
230                    columns={columns}
231                    rows={rows}
232                    cells={blockCells(thumbs.get(image.path)!, columns, rows)}
233                  />
234                ) : (
235                  <Image
236                    key={`image-${image.n}`}
237                    source={{ file: image.path, format: 'png' }}
238                    columns={columns}
239                    rows={rows}
240                    alt={`[Image #${image.n}]`}
241                  />
242                )}
243                <Text dimColor>#{image.n}</Text>
244              </Box>
245            )
246          })}
247          {hidden > 0 && <Text dimColor>+{hidden}</Text>}
248        </Box>
249        {below}
250      </Box>
251    )
252  })
253
254  // The same thumbnails on the sent message, which the band above the prompt clears on send.
255  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
256    if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer') return next(e)
257    const numbers = imageNumbers(e.props.text)
258    if (numbers.length === 0) return next(e)
259    const dir = await imagesDir($)
260    if (dir === undefined) return next(e)
261    const list: PastedImage[] = []
262    for (const n of numbers) {
263      const image = await describe($, dir, n)
264      if (image.path !== null) list.push(image)
265    }
266    if (list.length === 0) return next(e)
267
268    const { Box, Image, Raster, Text } = $.ui.resolve(e)
269    const row = await next(e)
270    return (
271      <Box flexDirection="column">
272        <Box flexDirection="row" columnGap={1}>
273          {list.map(({ n, path, size }) => {
274            const { columns, rows } = fitCells(size, SENT_TILE_ROWS)
275            const thumb = thumbs.get(path!)
276            return (
277              <Box key={`sent-${n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
278                {thumb ? (
279                  <Raster key={`blocks-${n}`} columns={columns} rows={rows} cells={blockCells(thumb, columns, rows)} />
280                ) : (
281                  <Image source={{ file: path!, format: 'png' }} columns={columns} rows={rows} alt={`[Image #${n}]`} />
282                )}
283                <Text dimColor>#{n}</Text>
284              </Box>
285            )
286          })}
287        </Box>
288        {row}
289      </Box>
290    )
291  })
292}
293
hooks/layout.ts 132 lines
1export type Size = { width: number; height: number }
2export type Cells = { columns: number; rows: number }
3
4const TILE_ROWS = 6
5const MAX_COLUMNS = 32
6const MIN_COLUMNS = 4
7// A terminal cell is about twice as tall as it is wide.
8const CELL_ASPECT = 2
9// Used when the size is unknown (file over $.fs.read's 4 MiB cap, or no file).
10const FALLBACK: Size = { width: 16, height: 10 }
11// Each tile adds a border on every side and a label row under the picture.
12const TILE_CHROME_ROWS = 3
13const TILE_CHROME_COLUMNS = 2
14const GAP = 1
15
16/** The distinct image numbers a draft references, in the order they first appear. */
17export function imageNumbers(draft: string): number[] {
18  const seen = new Set<number>()
19  for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
20  return [...seen]
21}
22
23/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
24export function pngSize(base64: string): Size | null {
25  // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
26  const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
27  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
28  if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
29  const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
30  const width = view.getUint32(16)
31  const height = view.getUint32(20)
32  return width > 0 && height > 0 ? { width, height } : null
33}
34
35/** A picture box `rows` tall that keeps the picture's aspect ratio. */
36export function fitCells(size: Size | null, tileRows = TILE_ROWS): Cells {
37  const { width, height } = size ?? FALLBACK
38  let rows = tileRows
39  let columns = Math.round((rows * CELL_ASPECT * width) / height)
40  if (columns > MAX_COLUMNS) {
41    columns = MAX_COLUMNS
42    rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
43  }
44  return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
45}
46
47/**
48 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
49 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
50 * When even one-row tiles are too wide, only the first ones that fit beside a `+N` label for
51 * the rest; none when the band is too small for one tile.
52 */
53export function fitRow(sizes: readonly (Size | null)[], maxRows: number, bodyColumns: number): Cells[] {
54  if (maxRows < TILE_CHROME_ROWS + 1) return []
55  const tallest = Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS)
56  for (let tileRows = tallest; tileRows > 1; tileRows--) {
57    const cells = sizes.map(size => fitCells(size, tileRows))
58    if (rowWidth(cells, 0) <= bodyColumns) return cells
59  }
60  const cells = sizes.map(size => fitCells(size, 1))
61  let count = cells.length
62  while (count > 0 && rowWidth(cells.slice(0, count), cells.length - count) > bodyColumns) count--
63  return cells.slice(0, count)
64}
65
66function rowWidth(cells: readonly Cells[], hidden: number): number {
67  const tiles = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * Math.max(0, cells.length - 1)
68  return hidden > 0 ? tiles + GAP + `+${hidden}`.length : tiles
69}
70
71type Env = Readonly<Record<string, string | undefined>>
72
73/**
74 * Whether Claude Code will draw `Image` as a real picture. It decides that itself and doesn't tell
75 * mods, so this mirrors its rule as far as the environment shows it: always when
76 * CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set; otherwise never inside tmux or screen or in a
77 * background session, and only when the terminal is kitty or Ghostty (it goes by the terminal's
78 * name, so other kitty-protocol terminals such as WezTerm still get the `alt` text).
79 * `CLAUDE_IMAGE_VIEW_RENDERER` overrides the guess: `image` for the picture, `blocks` for colored half blocks.
80 */
81export function drawsPictures(env: Env): boolean {
82  const forced = env.CLAUDE_IMAGE_VIEW_RENDERER
83  if (forced === 'image' || forced === 'blocks') return forced === 'image'
84  if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) return true
85  if (env.TMUX || env.STY || env.CLAUDE_CODE_SESSION_KIND === 'bg') return false
86  return (
87    Boolean(env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR) ||
88    /kitty|ghostty/i.test(env.TERM ?? '') ||
89    /^ghostty$/i.test(env.TERM_PROGRAM ?? '')
90  )
91}
92
93/**
94 * The cells of a `Raster` that draws `rgb` (a `width` x `height` thumbnail) in `columns` x `rows`
95 * cells, base64. Each cell is an upper half block, so it holds two pixels: the top as its
96 * foreground and the bottom as its background.
97 */
98export function blockCells(thumb: { width: number; height: number; rgb: Uint8Array }, columns: number, rows: number): string {
99  const pixelRows = rows * 2
100  const average = (px: number, py: number): number => {
101    // The box of source pixels under output pixel (px, py), at least one pixel wide and tall.
102    const x0 = Math.floor((px * thumb.width) / columns)
103    const x1 = Math.max(x0 + 1, Math.floor(((px + 1) * thumb.width) / columns))
104    const y0 = Math.floor((py * thumb.height) / pixelRows)
105    const y1 = Math.max(y0 + 1, Math.floor(((py + 1) * thumb.height) / pixelRows))
106    let r = 0
107    let g = 0
108    let b = 0
109    let n = 0
110    for (let y = y0; y < y1 && y < thumb.height; y++) {
111      for (let x = x0; x < x1 && x < thumb.width; x++, n++) {
112        const at = (y * thumb.width + x) * 3
113        r += thumb.rgb[at]!
114        g += thumb.rgb[at + 1]!
115        b += thumb.rgb[at + 2]!
116      }
117    }
118    n = n || 1
119    return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
120  }
121  const words = new Uint32Array(columns * rows * 3)
122  for (let row = 0; row < rows; row++) {
123    for (let column = 0; column < columns; column++) {
124      const at = (row * columns + column) * 3
125      words[at] = 0x2580
126      words[at + 1] = average(column, row * 2)
127      words[at + 2] = average(column, row * 2 + 1)
128    }
129  }
130  return (new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }).toBase64()
131}
132
hooks/png.ts 289 lines
1// A small PNG decoder for terminals that can't draw pictures. A mod has no zlib, so this
2// carries its own inflate. It box-averages the picture down to a thumbnail as it unfilters,
3// so a screenshot never has to sit in memory as RGBA.
4
5export type Thumb = { width: number; height: number; rgb: Uint8Array }
6
7// The longest side of the stored thumbnail, in pixels: more than any tile can show.
8const THUMB_SIDE = 96
9// What transparent pixels are composited over (the dark of most terminal themes).
10const BACKDROP = 0x1e
11// Refuse a picture whose raw scanlines would be bigger than this (an Adam7-free 8k x 8k RGBA).
12const MAX_RAW_BYTES = 256 * 1024 * 1024
13
14const 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]
15const 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]
16const 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]
17const 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]
18const CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
19
20type Huffman = { table: Uint32Array; bits: number }
21
22/** A lookup table indexed by the next `bits` input bits: (code length << 16) | symbol. */
23function huffman(lengths: ArrayLike<number>): Huffman {
24  let bits = 0
25  const count = new Array<number>(16).fill(0)
26  for (let i = 0; i < lengths.length; i++) {
27    count[lengths[i]!]!++
28    if (lengths[i]! > bits) bits = lengths[i]!
29  }
30  count[0] = 0
31  const next = new Array<number>(16).fill(0)
32  for (let len = 1, code = 0; len < 16; len++) {
33    code = (code + count[len - 1]!) << 1
34    next[len] = code
35  }
36  const size = 1 << Math.max(bits, 1)
37  const table = new Uint32Array(size)
38  for (let symbol = 0; symbol < lengths.length; symbol++) {
39    const len = lengths[symbol]!
40    if (len === 0) continue
41    let code = next[len]!++
42    let reversed = 0
43    for (let i = 0; i < len; i++, code >>= 1) reversed = (reversed << 1) | (code & 1)
44    for (let at = reversed; at < size; at += 1 << len) table[at] = (len << 16) | symbol
45  }
46  return { table, bits: Math.max(bits, 1) }
47}
48
49let fixedLiterals: Huffman | undefined
50let fixedDistances: Huffman | undefined
51
52/** Inflate a zlib stream into exactly `size` bytes; null when it is malformed or short. */
53export function inflate(input: Uint8Array, size: number): Uint8Array | null {
54  if (input.length < 2 || (input[0]! & 0x0f) !== 8) return null
55  const out = new Uint8Array(size)
56  let pos = 2
57  let written = 0
58  let buffer = 0
59  let held = 0
60
61  const need = (n: number) => {
62    while (held < n) {
63      buffer |= (pos < input.length ? input[pos]! : 0) << held
64      pos++
65      held += 8
66    }
67  }
68  const take = (n: number) => {
69    if (n === 0) return 0
70    need(n)
71    const value = buffer & ((1 << n) - 1)
72    buffer >>>= n
73    held -= n
74    return value
75  }
76  const decode = ({ table, bits }: Huffman) => {
77    need(bits)
78    const entry = table[buffer & ((1 << bits) - 1)]!
79    const len = entry >>> 16
80    if (len === 0) return -1
81    buffer >>>= len
82    held -= len
83    return entry & 0xffff
84  }
85
86  let last = false
87  while (!last) {
88    if (pos > input.length + 4) return null
89    last = take(1) === 1
90    const type = take(2)
91    if (type === 0) {
92      take(held & 7)
93      const len = take(16)
94      take(16)
95      for (let i = 0; i < len && written < size; i++) out[written++] = take(8)
96      continue
97    }
98    let literals: Huffman
99    let distances: Huffman
100    if (type === 1) {
101      if (!fixedLiterals || !fixedDistances) {
102        const lengths = new Uint8Array(288)
103        lengths.fill(8, 0, 144).fill(9, 144, 256).fill(7, 256, 280).fill(8, 280, 288)
104        fixedLiterals = huffman(lengths)
105        fixedDistances = huffman(new Uint8Array(30).fill(5))
106      }
107      literals = fixedLiterals
108      distances = fixedDistances
109    } else if (type === 2) {
110      const nLiterals = take(5) + 257
111      const nDistances = take(5) + 1
112      const nCodes = take(4) + 4
113      const codeLengths = new Uint8Array(19)
114      for (let i = 0; i < nCodes; i++) codeLengths[CODE_LENGTH_ORDER[i]!] = take(3)
115      const codes = huffman(codeLengths)
116      const lengths = new Uint8Array(nLiterals + nDistances)
117      for (let i = 0; i < lengths.length; ) {
118        const symbol = decode(codes)
119        if (symbol < 0) return null
120        if (symbol < 16) {
121          lengths[i++] = symbol
122          continue
123        }
124        const repeat = symbol === 16 ? 3 + take(2) : symbol === 17 ? 3 + take(3) : 11 + take(7)
125        const value = symbol === 16 ? lengths[i - 1]! : 0
126        if (symbol === 16 && i === 0) return null
127        for (let r = 0; r < repeat && i < lengths.length; r++) lengths[i++] = value
128      }
129      literals = huffman(lengths.subarray(0, nLiterals))
130      distances = huffman(lengths.subarray(nLiterals))
131    } else {
132      return null
133    }
134
135    for (;;) {
136      const symbol = decode(literals)
137      if (symbol < 0) return null
138      if (symbol < 256) {
139        if (written >= size) return null
140        out[written++] = symbol
141        continue
142      }
143      if (symbol === 256) break
144      const lengthIndex = symbol - 257
145      if (lengthIndex >= 29) return null
146      const length = LENGTH_BASE[lengthIndex]! + take(LENGTH_EXTRA[lengthIndex]!)
147      const distanceSymbol = decode(distances)
148      if (distanceSymbol < 0 || distanceSymbol >= 30) return null
149      const distance = DISTANCE_BASE[distanceSymbol]! + take(DISTANCE_EXTRA[distanceSymbol]!)
150      if (distance > written || written + length > size) return null
151      for (let i = 0; i < length; i++, written++) out[written] = out[written - distance]!
152    }
153  }
154  return written === size ? out : null
155}
156
157const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
158// Samples per pixel by PNG color type: gray, -, RGB, palette, gray + alpha, -, RGBA.
159const CHANNELS = [1, 0, 3, 1, 2, 0, 4]
160
161/**
162 * Decode a PNG into a small RGB thumbnail (longest side at most THUMB_SIDE), or null when it
163 * isn't a PNG, is interlaced, or is malformed. Transparent pixels are composited over a dark backdrop.
164 */
165export function decodeThumb(png: Uint8Array): Thumb | null {
166  if (png.length < 33 || SIGNATURE.some((byte, i) => png[i] !== byte)) return null
167  const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
168
169  let width = 0
170  let height = 0
171  let depth = 0
172  let colorType = 0
173  let palette = new Uint8Array(0)
174  let paletteAlpha = new Uint8Array(0)
175  const data: Uint8Array[] = []
176  for (let at = 8; at + 8 <= png.length; ) {
177    const length = view.getUint32(at)
178    const name = String.fromCharCode(png[at + 4]!, png[at + 5]!, png[at + 6]!, png[at + 7]!)
179    const body = png.subarray(at + 8, at + 8 + length)
180    if (body.length < length) return null
181    if (name === 'IHDR' && length >= 13) {
182      width = view.getUint32(at + 8)
183      height = view.getUint32(at + 12)
184      depth = body[8]!
185      colorType = body[9]!
186      if (body[10] !== 0 || body[11] !== 0 || body[12] !== 0) return null // not deflate, or interlaced
187    } else if (name === 'PLTE') palette = body
188    else if (name === 'tRNS') paletteAlpha = body
189    else if (name === 'IDAT') data.push(body)
190    else if (name === 'IEND') break
191    at += 12 + length
192  }
193  const channels = CHANNELS[colorType] ?? 0
194  if (width === 0 || height === 0 || channels === 0 || ![1, 2, 4, 8, 16].includes(depth)) return null
195  if (colorType === 3 && (palette.length === 0 || depth === 16)) return null
196
197  const bitsPerPixel = channels * depth
198  const stride = Math.ceil((width * bitsPerPixel) / 8)
199  const step = Math.max(1, bitsPerPixel >> 3)
200  if ((stride + 1) * height > MAX_RAW_BYTES) return null
201
202  const joined = new Uint8Array(data.reduce((sum, chunk) => sum + chunk.length, 0))
203  for (let i = 0, offset = 0; i < data.length; offset += data[i]!.length, i++) joined.set(data[i]!, offset)
204  const raw = inflate(joined, (stride + 1) * height)
205  if (!raw) return null
206
207  const scale = Math.min(1, THUMB_SIDE / Math.max(width, height))
208  const tw = Math.max(1, Math.round(width * scale))
209  const th = Math.max(1, Math.round(height * scale))
210  const sums = new Float64Array(tw * th * 3)
211  const counts = new Uint32Array(tw * th)
212  const columnOf = new Uint16Array(width)
213  for (let x = 0; x < width; x++) columnOf[x] = Math.min(tw - 1, Math.floor((x * tw) / width))
214
215  const max = (1 << Math.min(depth, 8)) - 1
216  const unit = depth === 16 ? 1 : 255 / max
217  let prior = new Uint8Array(stride)
218  let line = new Uint8Array(stride)
219  const sample = (x: number, channel: number) => {
220    if (depth === 8) return line[x * channels + channel]!
221    if (depth === 16) return line[(x * channels + channel) * 2]!
222    const bit = (x * channels + channel) * depth
223    return (line[bit >> 3]! >> (8 - depth - (bit & 7))) & max
224  }
225
226  for (let y = 0; y < height; y++) {
227    const filter = raw[y * (stride + 1)]!
228    const source = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
229    for (let i = 0; i < stride; i++) {
230      const left = i >= step ? line[i - step]! : 0
231      const up = prior[i]!
232      const upLeft = i >= step ? prior[i - step]! : 0
233      let predicted = 0
234      if (filter === 1) predicted = left
235      else if (filter === 2) predicted = up
236      else if (filter === 3) predicted = (left + up) >> 1
237      else if (filter === 4) {
238        const p = left + up - upLeft
239        const pa = Math.abs(p - left)
240        const pb = Math.abs(p - up)
241        const pc = Math.abs(p - upLeft)
242        predicted = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft
243      } else if (filter !== 0) return null
244      line[i] = (source[i]! + predicted) & 0xff
245    }
246
247    const row = Math.min(th - 1, Math.floor((y * th) / height))
248    for (let x = 0; x < width; x++) {
249      let r: number
250      let g: number
251      let b: number
252      let a = 255
253      if (colorType === 2 || colorType === 6) {
254        r = sample(x, 0)
255        g = sample(x, 1)
256        b = sample(x, 2)
257        if (colorType === 6) a = sample(x, 3)
258      } else if (colorType === 3) {
259        const index = sample(x, 0)
260        r = palette[index * 3] ?? 0
261        g = palette[index * 3 + 1] ?? 0
262        b = palette[index * 3 + 2] ?? 0
263        a = paletteAlpha[index] ?? 255
264      } else {
265        r = g = b = Math.round(sample(x, 0) * unit)
266        if (colorType === 4) a = sample(x, 1)
267      }
268      if (a < 255) {
269        r = (r * a + BACKDROP * (255 - a)) / 255
270        g = (g * a + BACKDROP * (255 - a)) / 255
271        b = (b * a + BACKDROP * (255 - a)) / 255
272      }
273      const cell = row * tw + columnOf[x]!
274      sums[cell * 3] += r
275      sums[cell * 3 + 1] += g
276      sums[cell * 3 + 2] += b
277      counts[cell]!++
278    }
279    ;[prior, line] = [line, prior]
280  }
281
282  const rgb = new Uint8Array(tw * th * 3)
283  for (let cell = 0; cell < tw * th; cell++) {
284    const n = counts[cell] || 1
285    for (let c = 0; c < 3; c++) rgb[cell * 3 + c] = Math.round(sums[cell * 3 + c]! / n)
286  }
287  return { width: tw, height: th, rgb }
288}
289
types/index.d.ts 14 lines
1export type PastedImage = {
2  n: number
3  /** Absolute path of the PNG to draw (the cached paste or its converted copy); null when it can't be found. */
4  path: string | null
5  /** Pixel size; null when unknown, and the tile falls back to a default shape. */
6  size: { width: number; height: number } | null
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'image-preview': { images: PastedImage[] }
12  }
13}
14