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

A Claude Code mod for pasted images. It does three things:
[Image #1] tag.Real pictures in Ghostty and kitty; colored half blocks in other terminals. Non-PNG pastes are converted with sips (macOS).
/plugin install image-preview --marketplace lpareja99/claude-mods
/reload-plugins
The prompt thumbnails, PNG decoding and layout code are adapted from jarrodwatts/claude-image-view (MIT, see LICENSE).
hooks/register.tsx 293 lines1// 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}
293hooks/layout.ts 132 lines1export 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}
132hooks/png.ts 289 lines1// 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}
289types/index.d.ts 14 lines1export 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