Shows a thumbnail of each [Image #N] pasted into the prompt box, above the prompt, with a button that opens the original

Claude Code 的一个 mod:在终端里往输入框粘贴图片后,输入框里只有一行 [Image #1],看不到贴的是哪张图。装上这个 mod,输入框上方会显示每张已粘贴图片的缩略图,还有一个「打开原图」按钮,点一下就用系统自带的看图软件打开原图。
[Image #1] 1920×1080
[ 打开原图 ]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀ 彩色缩略图(半格字符)▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
────────────────────────────────────────
❯ 看看这张图 [Image #1]
在 Claude Code 终端会话的输入框里输入:
/plugin install paste-image-preview --marketplace liu052100/claude-paste-image-preview
提示是否添加插件市场时输入 y,再选择安装范围。
也可以在命令行里装:
claude plugin install paste-image-preview --marketplace liu052100/claude-paste-image-preview
需要支持 mods(function hooks)的 Claude Code 版本,作者在 2.1.304 上测试过。
[Image #N] 文字,对应的缩略图就会消失;消息发出后,缩略图全部消失。Ctrl+X 再按 Tab 把焦点切到这块区域,然后按数字键 1–4(第几张图就按几)。▀)拼出来的,一个字符格显示上下两个像素,能看出是哪张图,看不清图里的字。要看清楚请点「打开原图」。终端需要支持 24 位真彩色。explorer.exe,在 macOS 上调用 open,在 Linux 上调用 xdg-open。作者只在 Windows 上实测过。<临时目录>/claude/<项目>/<会话>/images/N.png)。这个位置不是公开约定,Claude Code 以后要是改了位置,mod 会提示「找不到本会话的图片缓存目录」。[Image #N] 不会显示缩略图。claude plugin validate .
claude plugin test .
tests/fixtures.ts 是测试用的 PNG 样本,由 python tests/gen_fixtures.py tests/fixtures.ts 生成(需要安装 Pillow)。
A Claude Code mod. When you paste an image into the prompt box in the terminal, all you see is [Image #1]. This mod draws a thumbnail of each pasted image above the prompt, with an 打开原图 ("open original") button that opens the full image in your system's viewer.
At the prompt of a Claude Code terminal session:
/plugin install paste-image-preview --marketplace liu052100/claude-paste-image-preview
Answer y to add the marketplace, then pick a scope. From a shell:
claude plugin install paste-image-preview --marketplace liu052100/claude-paste-image-preview
Requires a Claude Code build with mods (function hooks); tested on 2.1.304.
[Image #N] width×height, the open button, and the thumbnail.[Image #N] from the draft and its thumbnail goes; sending the prompt clears them all.Ctrl+X then Tab to focus the band and press 1–4.explorer.exe on Windows, open on macOS, xdg-open on Linux; only tested on Windows.<temp>/claude/<project>/<session>/images/N.png). That location is not a public contract; if it changes, the mod says it cannot find the cache folder.claude plugin validate .
claude plugin test .
tests/fixtures.ts holds the test PNGs, made by python tests/gen_fixtures.py tests/fixtures.ts (needs Pillow).
MIT
hooks/register.tsx 184 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ImageIds } from '../types'
5import { cells, decodePng, fit, fromBase64 } from './png'
6import type { Decoded } from './png'
7
8const ids = atom({ plugin: 'paste-image-preview', key: 'ids' } as const, [] as ImageIds)
9
10const PLACEHOLDER = /\[Image #(\d+)\]/g
11const MAX_SHOWN = 4
12const MAX_COLUMNS = 48
13const MAX_ROWS = 12
14const GAP = 2
15// What one read of `$.fs.read` may copy in.
16const MAX_BYTES = 4 * 1024 * 1024
17// The size a picture is decoded down to once, before each layout's resize.
18const DECODE_WIDTH = MAX_COLUMNS * 2
19const DECODE_HEIGHT = MAX_ROWS * 4
20
21// The image's file, when there is one to open, and its picture or why not.
22type Found = { file?: string } & ({ decoded: Decoded } | { reason: string })
23
24// The draft's last image list, so a keystroke that keeps it writes nothing.
25let lastSeen: string | undefined
26// Decoded pictures by path, size and mtime, so a redraw decodes nothing.
27const decodedByFile = new Map<string, Found>()
28let dirOfSession: { session: string; dir: string } | undefined
29
30async function sync($: EngineInterface, text: string): Promise<void> {
31 const found = [...new Set([...text.matchAll(PLACEHOLDER)].map(m => Number(m[1])))]
32 const seen = found.join(',')
33 if (seen === lastSeen) return
34 lastSeen = seen
35 await update($, ids, () => found)
36}
37
38// Where the engine keeps this session's pasted images:
39// <temp>/claude/<project, every non-alphanumeric as '-'>/<session id>/images
40async function imagesDir($: EngineInterface): Promise<string | undefined> {
41 const session = await $.session.id()
42 if (dirOfSession?.session === session) return dirOfSession.dir
43 const temps = [
44 await $.env.get('CLAUDE_CODE_TMPDIR'),
45 await $.env.get('TEMP'),
46 await $.env.get('TMP'),
47 await $.env.get('TMPDIR'),
48 ]
49 const roots = [...new Set(temps.filter((t): t is string => !!t))]
50 const projects = [...new Set([await $.session.root(), await $.session.cwd()])].map(p =>
51 p.replace(/[^a-zA-Z0-9]/g, '-'),
52 )
53 for (const root of roots) {
54 const sep = root.includes('\\') ? '\\' : '/'
55 const base = `${root.replace(/[\\/]+$/, '')}${sep}claude`
56 if (!(await $.fs.exists(base))) continue
57 const candidates = projects.map(project => [base, project, session, 'images'].join(sep))
58 // A project path the engine names otherwise (a long one, shortened).
59 for (const entry of await $.fs.list(base)) {
60 if (entry.kind === 'dir') candidates.push([base, entry.name, session, 'images'].join(sep))
61 }
62 for (const dir of candidates) {
63 if (await $.fs.exists(dir)) {
64 dirOfSession = { session, dir }
65 return dir
66 }
67 }
68 }
69 return undefined
70}
71
72async function load($: EngineInterface, dir: string, id: number): Promise<Found> {
73 const entry = (await $.fs.list(dir)).find(f => f.kind === 'file' && f.name.startsWith(`${id}.`))
74 if (!entry) return { reason: '缓存目录里没有这张图' }
75 const file = `${dir}${dir.includes('\\') ? '\\' : '/'}${entry.name}`
76 if (!entry.name.toLowerCase().endsWith('.png')) return { file, reason: `${entry.name} 不是 PNG,暂不支持预览` }
77 if (entry.size > MAX_BYTES) {
78 return { file, reason: `图片 ${(entry.size / 1048576).toFixed(1)}MB,超过插件可读的 4MB,未预览` }
79 }
80 const cacheKey = `${file}|${entry.size}|${entry.mtimeMs}`
81 const cached = decodedByFile.get(cacheKey)
82 if (cached) return cached
83 let found: Found
84 try {
85 const { base64 } = await $.fs.read(file, { as: 'bytes' })
86 found = { file, decoded: decodePng(fromBase64(base64), DECODE_WIDTH, DECODE_HEIGHT) }
87 } catch (error) {
88 found = { file, reason: `解码失败:${error instanceof Error ? error.message : String(error)}` }
89 }
90 decodedByFile.set(cacheKey, found)
91 return found
92}
93
94// Opens the original in the system's own viewer.
95async function openFile($: EngineInterface, file: string): Promise<void> {
96 try {
97 if (file.includes('\\')) {
98 // explorer.exe answers 1 even when it opened the file.
99 const { exitCode, stderr } = await $.process.run(['explorer.exe', file])
100 if (exitCode > 1) $.ui.toast(`打开原图失败(${exitCode}):${stderr.trim() || file}`)
101 return
102 }
103 const { exitCode, stderr } = await $.process.run(['open', file]).catch(() => $.process.run(['xdg-open', file]))
104 if (exitCode !== 0) $.ui.toast(`打开原图失败(${exitCode}):${stderr.trim() || file}`)
105 } catch (error) {
106 $.ui.toast(`打开原图失败:${error instanceof Error ? error.message : String(error)}`)
107 }
108}
109
110export const register: Register = on => {
111 on('session.start', async ($, e, next) => {
112 const started = await next(e)
113 lastSeen = undefined
114 await sync($, (await $.prompt.read()).text)
115 return started
116 })
117
118 on('prompt.edit', async ($, e, next) => {
119 const box = await next(e)
120 await sync($, box.text)
121 return box
122 })
123
124 on('prompt.change', async ($, e, next) => {
125 await sync($, e.text)
126 return next(e)
127 })
128
129 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
131 const shown = await read($, ids)
132 if (shown.length === 0) return next(e)
133
134 const { Box, Button, Text, Raster } = $.ui.resolve(e)
135 const dir = await imagesDir($)
136 const list = shown.slice(0, MAX_SHOWN)
137 const across = Math.floor((e.props.bodyColumns - GAP * (list.length - 1)) / list.length)
138 const isRow = across >= 12
139 const maxColumns = Math.max(4, Math.min(MAX_COLUMNS, isRow ? across : e.props.bodyColumns))
140 // The heading and the button take a row each.
141 const maxRows = Math.max(2, Math.min(MAX_ROWS, e.props.maxRows - 3))
142
143 const items = []
144 for (const [index, id] of list.entries()) {
145 const found: Found = dir ? await load($, dir, id) : { reason: '找不到本会话的图片缓存目录' }
146 const { file } = found
147 const box = 'decoded' in found ? fit(found.decoded.width, found.decoded.height, maxColumns, maxRows) : undefined
148 items.push(
149 <Box key={`item-${id}`} flexDirection="column" width={isRow ? maxColumns : undefined}>
150 <Text dimColor wrap="truncate">
151 [Image #{id}] {'decoded' in found ? `${found.decoded.width}×${found.decoded.height}` : found.reason}
152 </Text>
153 {file ? (
154 <Button
155 key={`open-${id}`}
156 label="打开原图"
157 hotkey={String(index + 1)}
158 dimColor
159 onPress={() => openFile($, file)}
160 />
161 ) : null}
162 {'decoded' in found && box ? (
163 <Raster
164 key={`image-${id}`}
165 columns={box.columns}
166 rows={box.rows}
167 cells={cells(found.decoded.small, box.columns, box.rows)}
168 />
169 ) : null}
170 </Box>,
171 )
172 }
173
174 return (
175 <Box flexDirection="column">
176 <Box flexDirection={isRow ? 'row' : 'column'} columnGap={GAP}>
177 {items}
178 </Box>
179 {shown.length > MAX_SHOWN ? <Text dimColor>还有 {shown.length - MAX_SHOWN} 张未显示</Text> : null}
180 </Box>
181 )
182 })
183}
184hooks/png.ts 457 lines1// A PNG decoder small enough to live in a hooks module (no Node, no
2// DecompressionStream), and the half-block cells a Raster draws a picture as.
3
4const LBASE = [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]
5const LEXT = [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]
6const DBASE = [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]
7const DEXT = [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]
8const CLORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
9
10// A canonical Huffman code as one lookup table indexed by the next `bits`
11// input bits (LSB first): each entry is `symbol << 4 | codeLength`.
12type Huffman = { table: Uint16Array; bits: number }
13
14function huffman(lengths: ArrayLike<number>): Huffman {
15 let bits = 0
16 const count = new Uint16Array(16)
17 for (let i = 0; i < lengths.length; i++) {
18 const len = lengths[i] ?? 0
19 count[len] = (count[len] ?? 0) + 1
20 if (len > bits) bits = len
21 }
22 count[0] = 0
23 bits = Math.max(bits, 1)
24 const next = new Uint16Array(16)
25 let code = 0
26 for (let len = 1; len < 16; len++) {
27 code = (code + (count[len - 1] ?? 0)) << 1
28 next[len] = code
29 }
30 const table = new Uint16Array(1 << bits)
31 for (let symbol = 0; symbol < lengths.length; symbol++) {
32 const len = lengths[symbol] ?? 0
33 if (len === 0) continue
34 const c = next[len] ?? 0
35 next[len] = c + 1
36 let reversed = 0
37 for (let i = 0; i < len; i++) reversed |= ((c >> i) & 1) << (len - 1 - i)
38 for (let i = reversed; i < table.length; i += 1 << len) table[i] = (symbol << 4) | len
39 }
40 return { table, bits }
41}
42
43let fixedLit: Huffman | undefined
44let fixedDist: Huffman | undefined
45
46function fixedCodes(): [Huffman, Huffman] {
47 if (!fixedLit || !fixedDist) {
48 const lit = new Uint8Array(288)
49 lit.fill(8, 0, 144)
50 lit.fill(9, 144, 256)
51 lit.fill(7, 256, 280)
52 lit.fill(8, 280, 288)
53 fixedLit = huffman(lit)
54 fixedDist = huffman(new Uint8Array(30).fill(5))
55 }
56 return [fixedLit, fixedDist]
57}
58
59// Inflates a zlib stream into `out`, which the caller sizes exactly.
60export function inflate(src: Uint8Array, out: Uint8Array): void {
61 if (((src[0] ?? 0) & 15) !== 8) throw new Error('not a deflate stream')
62 let pos = 2
63 let buf = 0
64 let cnt = 0
65 let o = 0
66
67 const need = (n: number): void => {
68 while (cnt < n) {
69 if (pos > src.length + 4) throw new Error('compressed data ends early')
70 buf |= (src[pos++] ?? 0) << cnt
71 cnt += 8
72 }
73 }
74 const take = (n: number): number => {
75 if (n === 0) return 0
76 need(n)
77 const v = buf & ((1 << n) - 1)
78 buf >>>= n
79 cnt -= n
80 return v
81 }
82 const symbol = (h: Huffman): number => {
83 need(h.bits)
84 const entry = h.table[buf & ((1 << h.bits) - 1)] ?? 0
85 const len = entry & 15
86 if (len === 0) throw new Error('bad Huffman code')
87 buf >>>= len
88 cnt -= len
89 return entry >> 4
90 }
91
92 for (;;) {
93 const isFinal = take(1)
94 const type = take(2)
95 if (type === 0) {
96 take(cnt & 7)
97 const len = take(16)
98 take(16)
99 pos -= cnt >> 3
100 buf = 0
101 cnt = 0
102 if (pos + len > src.length || o + len > out.length) throw new Error('stored block overruns')
103 out.set(src.subarray(pos, pos + len), o)
104 pos += len
105 o += len
106 } else {
107 let lit: Huffman
108 let dist: Huffman
109 if (type === 1) {
110 ;[lit, dist] = fixedCodes()
111 } else if (type === 2) {
112 const hlit = take(5) + 257
113 const hdist = take(5) + 1
114 const hclen = take(4) + 4
115 const clens = new Uint8Array(19)
116 for (let i = 0; i < hclen; i++) clens[CLORDER[i] ?? 0] = take(3)
117 const cl = huffman(clens)
118 const lens = new Uint8Array(hlit + hdist)
119 for (let i = 0; i < lens.length; ) {
120 const s = symbol(cl)
121 if (s < 16) {
122 lens[i++] = s
123 continue
124 }
125 let value = 0
126 let repeat: number
127 if (s === 16) {
128 if (i === 0) throw new Error('repeat with no length')
129 value = lens[i - 1] ?? 0
130 repeat = 3 + take(2)
131 } else if (s === 17) {
132 repeat = 3 + take(3)
133 } else {
134 repeat = 11 + take(7)
135 }
136 if (i + repeat > lens.length) throw new Error('too many lengths')
137 lens.fill(value, i, i + repeat)
138 i += repeat
139 }
140 lit = huffman(lens.subarray(0, hlit))
141 dist = huffman(lens.subarray(hlit))
142 } else {
143 throw new Error('bad block type')
144 }
145 for (;;) {
146 const s = symbol(lit)
147 if (s < 256) {
148 if (o >= out.length) throw new Error('output overruns')
149 out[o++] = s
150 } else if (s === 256) {
151 break
152 } else {
153 const li = s - 257
154 const len = (LBASE[li] ?? 0) + take(LEXT[li] ?? 0)
155 const di = symbol(dist)
156 const d = (DBASE[di] ?? 0) + take(DEXT[di] ?? 0)
157 if (d > o || o + len > out.length) throw new Error('bad back reference')
158 for (let i = 0; i < len; i++, o++) out[o] = out[o - d] ?? 0
159 }
160 }
161 }
162 if (isFinal) break
163 }
164 if (o !== out.length) throw new Error('image data is short')
165}
166
167// Straight (not premultiplied) RGBA pixels.
168export type Pixels = { width: number; height: number; rgba: Uint8Array }
169
170// The decoded picture, box-averaged down to fit `maxWidth` x `maxHeight`
171// pixels (never scaled up), with its original size.
172export type Decoded = { width: number; height: number; small: Pixels }
173
174const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }
175
176export function decodePng(file: Uint8Array, maxWidth: number, maxHeight: number): Decoded {
177 const SIG = [137, 80, 78, 71, 13, 10, 26, 10]
178 if (SIG.some((b, i) => file[i] !== b)) throw new Error('not a PNG file')
179 const view = new DataView(file.buffer, file.byteOffset, file.byteLength)
180 let width = 0
181 let height = 0
182 let depth = 0
183 let color = 0
184 let interlace = 0
185 let palette: Uint8Array | undefined
186 let trns: Uint8Array | undefined
187 const parts: Uint8Array[] = []
188 let idatSize = 0
189 for (let p = 8; p + 8 <= file.length; ) {
190 const len = view.getUint32(p)
191 const type = String.fromCharCode(file[p + 4] ?? 0, file[p + 5] ?? 0, file[p + 6] ?? 0, file[p + 7] ?? 0)
192 const data = file.subarray(p + 8, p + 8 + len)
193 if (type === 'IHDR') {
194 width = view.getUint32(p + 8)
195 height = view.getUint32(p + 12)
196 depth = data[8] ?? 0
197 color = data[9] ?? 0
198 interlace = data[12] ?? 0
199 } else if (type === 'PLTE') {
200 palette = data
201 } else if (type === 'tRNS') {
202 trns = data
203 } else if (type === 'IDAT') {
204 parts.push(data)
205 idatSize += data.length
206 } else if (type === 'IEND') {
207 break
208 }
209 p += 12 + len
210 }
211 const channels = CHANNELS[color]
212 if (!width || !height || channels === undefined) throw new Error('unsupported PNG header')
213 if (interlace) throw new Error('interlaced PNG')
214 if (color === 3 && !palette) throw new Error('palette missing')
215
216 const idat = new Uint8Array(idatSize)
217 let at = 0
218 for (const part of parts) {
219 idat.set(part, at)
220 at += part.length
221 }
222 const stride = Math.ceil((width * channels * depth) / 8)
223 const raw = new Uint8Array(height * (stride + 1))
224 inflate(idat, raw)
225 unfilter(raw, height, stride, Math.max(1, (channels * depth) >> 3))
226
227 const scale = Math.min(1, maxWidth / width, maxHeight / height)
228 const sw = Math.max(1, Math.round(width * scale))
229 const sh = Math.max(1, Math.round(height * scale))
230 const sums = new Float64Array(sw * sh * 5)
231 const colOf = new Uint32Array(width)
232 for (let x = 0; x < width; x++) colOf[x] = Math.min(sw - 1, Math.floor((x * sw) / width))
233 const px = new Uint8Array(4)
234 const key = trns && color !== 3 ? trnsKey(trns, color, depth) : undefined
235 // A big picture is sampled on a grid, still at least 4 x 4 per pixel kept.
236 const step = Math.max(1, Math.floor(Math.min(width / sw, height / sh) / 4))
237 const isRgba8 = depth === 8 && color === 6
238 const isRgb8 = depth === 8 && color === 2 && !key
239
240 for (let y = 0; y < height; y += step) {
241 const row = y * (stride + 1) + 1
242 const base = Math.min(sh - 1, Math.floor((y * sh) / height)) * sw
243 for (let x = 0; x < width; x += step) {
244 let r: number
245 let g: number
246 let b: number
247 let a: number
248 if (isRgba8 || isRgb8) {
249 const i = row + x * (isRgba8 ? 4 : 3)
250 r = raw[i] ?? 0
251 g = raw[i + 1] ?? 0
252 b = raw[i + 2] ?? 0
253 a = isRgba8 ? (raw[i + 3] ?? 0) : 255
254 } else {
255 pixel(raw, row, x, color, depth, palette, trns, key, px)
256 r = px[0] ?? 0
257 g = px[1] ?? 0
258 b = px[2] ?? 0
259 a = px[3] ?? 0
260 }
261 const s = (base + (colOf[x] ?? 0)) * 5
262 sums[s] = (sums[s] ?? 0) + r * a
263 sums[s + 1] = (sums[s + 1] ?? 0) + g * a
264 sums[s + 2] = (sums[s + 2] ?? 0) + b * a
265 sums[s + 3] = (sums[s + 3] ?? 0) + a
266 sums[s + 4] = (sums[s + 4] ?? 0) + 1
267 }
268 }
269 return { width, height, small: { width: sw, height: sh, rgba: average(sums, sw * sh) } }
270}
271
272function unfilter(raw: Uint8Array, height: number, stride: number, bpp: number): void {
273 for (let y = 0; y < height; y++) {
274 const f = raw[y * (stride + 1)] ?? 0
275 const r = y * (stride + 1) + 1
276 const up = r - (stride + 1)
277 const hasUp = y > 0
278 for (let i = 0; i < stride; i++) {
279 const left = i >= bpp ? (raw[r + i - bpp] ?? 0) : 0
280 const above = hasUp ? (raw[up + i] ?? 0) : 0
281 const corner = hasUp && i >= bpp ? (raw[up + i - bpp] ?? 0) : 0
282 let add = 0
283 if (f === 1) add = left
284 else if (f === 2) add = above
285 else if (f === 3) add = (left + above) >> 1
286 else if (f === 4) {
287 const p = left + above - corner
288 const pa = Math.abs(p - left)
289 const pb = Math.abs(p - above)
290 const pc = Math.abs(p - corner)
291 add = pa <= pb && pa <= pc ? left : pb <= pc ? above : corner
292 } else if (f !== 0) throw new Error('bad filter type')
293 raw[r + i] = ((raw[r + i] ?? 0) + add) & 255
294 }
295 }
296}
297
298// The colour a tRNS chunk makes transparent in a greyscale or RGB image, as
299// the samples `pixel` reads (their top 8 bits).
300function trnsKey(trns: Uint8Array, color: number, depth: number): number[] {
301 const sample = (i: number): number => {
302 const v = ((trns[i * 2] ?? 0) << 8) | (trns[i * 2 + 1] ?? 0)
303 return depth === 16 ? v >> 8 : depth === 8 ? v : Math.round((v * 255) / ((1 << depth) - 1))
304 }
305 return color === 0 ? [sample(0)] : [sample(0), sample(1), sample(2)]
306}
307
308function pixel(
309 raw: Uint8Array,
310 row: number,
311 x: number,
312 color: number,
313 depth: number,
314 palette: Uint8Array | undefined,
315 trns: Uint8Array | undefined,
316 key: number[] | undefined,
317 out: Uint8Array,
318): void {
319 const channels = CHANNELS[color] ?? 1
320 const sample = (c: number): number => {
321 if (depth === 8) return raw[row + x * channels + c] ?? 0
322 if (depth === 16) return raw[row + (x * channels + c) * 2] ?? 0
323 const bit = (x * channels + c) * depth
324 const v = ((raw[row + (bit >> 3)] ?? 0) >> (8 - depth - (bit & 7))) & ((1 << depth) - 1)
325 return color === 3 ? v : Math.round((v * 255) / ((1 << depth) - 1))
326 }
327 if (color === 3) {
328 const i = sample(0)
329 out[0] = palette?.[i * 3] ?? 0
330 out[1] = palette?.[i * 3 + 1] ?? 0
331 out[2] = palette?.[i * 3 + 2] ?? 0
332 out[3] = trns && i < trns.length ? (trns[i] ?? 255) : 255
333 } else if (color === 0 || color === 4) {
334 const g = sample(0)
335 out[0] = g
336 out[1] = g
337 out[2] = g
338 out[3] = color === 4 ? sample(1) : key && key[0] === g ? 0 : 255
339 } else {
340 out[0] = sample(0)
341 out[1] = sample(1)
342 out[2] = sample(2)
343 out[3] =
344 color === 6 ? sample(3) : key && key[0] === out[0] && key[1] === out[1] && key[2] === out[2] ? 0 : 255
345 }
346}
347
348function average(sums: Float64Array, n: number): Uint8Array {
349 const rgba = new Uint8Array(n * 4)
350 for (let i = 0; i < n; i++) {
351 const a = sums[i * 5 + 3] ?? 0
352 const count = sums[i * 5 + 4] ?? 0
353 if (a > 0) {
354 rgba[i * 4] = Math.round((sums[i * 5] ?? 0) / a)
355 rgba[i * 4 + 1] = Math.round((sums[i * 5 + 1] ?? 0) / a)
356 rgba[i * 4 + 2] = Math.round((sums[i * 5 + 2] ?? 0) / a)
357 }
358 rgba[i * 4 + 3] = count > 0 ? Math.round(a / count) : 0
359 }
360 return rgba
361}
362
363// Box-averages straight RGBA pixels down (or up, by nearest) to w x h.
364export function resize(src: Pixels, w: number, h: number): Pixels {
365 const sums = new Float64Array(w * h * 5)
366 for (let y = 0; y < src.height; y++) {
367 const ty = Math.min(h - 1, Math.floor((y * h) / src.height))
368 for (let x = 0; x < src.width; x++) {
369 const tx = Math.min(w - 1, Math.floor((x * w) / src.width))
370 const i = (y * src.width + x) * 4
371 const a = src.rgba[i + 3] ?? 0
372 const s = (ty * w + tx) * 5
373 sums[s] = (sums[s] ?? 0) + (src.rgba[i] ?? 0) * a
374 sums[s + 1] = (sums[s + 1] ?? 0) + (src.rgba[i + 1] ?? 0) * a
375 sums[s + 2] = (sums[s + 2] ?? 0) + (src.rgba[i + 2] ?? 0) * a
376 sums[s + 3] = (sums[s + 3] ?? 0) + a
377 sums[s + 4] = (sums[s + 4] ?? 0) + 1
378 }
379 }
380 const out = average(sums, w * h)
381 // A target pixel no source pixel landed in (scaling up) takes its nearest.
382 for (let ty = 0; ty < h; ty++) {
383 for (let tx = 0; tx < w; tx++) {
384 const s = (ty * w + tx) * 5
385 if ((sums[s + 4] ?? 0) > 0) continue
386 const sx = Math.min(src.width - 1, Math.floor(((tx + 0.5) * src.width) / w))
387 const sy = Math.min(src.height - 1, Math.floor(((ty + 0.5) * src.height) / h))
388 out.set(src.rgba.subarray((sy * src.width + sx) * 4, (sy * src.width + sx) * 4 + 4), (ty * w + tx) * 4)
389 }
390 }
391 return { width: w, height: h, rgba: out }
392}
393
394// The cell box a picture of `width` x `height` pixels fits in, within
395// `maxColumns` x `maxRows`, keeping its shape: a cell is two square
396// half-cell pixels stacked.
397export function fit(width: number, height: number, maxColumns: number, maxRows: number): { columns: number; rows: number } {
398 const scale = Math.min(maxColumns / width, (maxRows * 2) / height)
399 return {
400 columns: Math.max(1, Math.min(maxColumns, Math.round(width * scale))),
401 rows: Math.max(1, Math.min(maxRows, Math.round((height * scale) / 2))),
402 }
403}
404
405const UPPER = 0x2580
406const LOWER = 0x2584
407const DEFAULT = 0x01000000
408
409// A Raster's `cells`: each cell the upper half block, its foreground the top
410// pixel and its background the bottom one; a transparent half shows the
411// terminal's own background.
412export function cells(src: Pixels, columns: number, rows: number): string {
413 const px = resize(src, columns, rows * 2)
414 const words = new Uint32Array(columns * rows * 3)
415 const rgb = (i: number): number =>
416 ((px.rgba[i] ?? 0) << 16) | ((px.rgba[i + 1] ?? 0) << 8) | (px.rgba[i + 2] ?? 0)
417 for (let y = 0; y < rows; y++) {
418 for (let x = 0; x < columns; x++) {
419 const top = (y * 2 * columns + x) * 4
420 const bottom = ((y * 2 + 1) * columns + x) * 4
421 const isTop = (px.rgba[top + 3] ?? 0) >= 128
422 const isBottom = (px.rgba[bottom + 3] ?? 0) >= 128
423 const w = (y * columns + x) * 3
424 if (isTop) {
425 words[w] = UPPER
426 words[w + 1] = rgb(top)
427 words[w + 2] = isBottom ? rgb(bottom) : DEFAULT
428 } else if (isBottom) {
429 words[w] = LOWER
430 words[w + 1] = rgb(bottom)
431 words[w + 2] = DEFAULT
432 } else {
433 words[w] = 0x20
434 words[w + 1] = DEFAULT
435 words[w + 2] = DEFAULT
436 }
437 }
438 }
439 return toBase64(new Uint8Array(words.buffer))
440}
441
442// Base64 through atob/btoa, which every hooks environment has.
443export function fromBase64(text: string): Uint8Array {
444 const binary = atob(text)
445 const bytes = new Uint8Array(binary.length)
446 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
447 return bytes
448}
449
450export function toBase64(bytes: Uint8Array): string {
451 let binary = ''
452 for (let i = 0; i < bytes.length; i += 0x8000) {
453 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
454 }
455 return btoa(binary)
456}
457types/index.d.ts 9 lines1// The image numbers the draft names as `[Image #N]`, in order, each once.
2export type ImageIds = number[]
3
4declare module 'claude-code' {
5 interface PluginState {
6 'paste-image-preview': { ids: ImageIds }
7 }
8}
9