SLOPSHOPPER

paste-image-preview

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

newbandtoastpromptprocess
★ 1v0.1.0MITupdated 2026-10-20liu052100/claude-paste-image-preview
A shopper browsing a rack in a slop shop
README

paste-image-preview

English

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] 宽×高」、「打开原图」按钮和缩略图。
  • 一次最多并排显示 4 张,再多的会提示「还有 N 张未显示」。
  • 删掉输入框里的 [Image #N] 文字,对应的缩略图就会消失;消息发出后,缩略图全部消失。
  • 打开原图:用鼠标点按钮;或者按 Ctrl+X 再按 Tab 把焦点切到这块区域,然后按数字键 1–4(第几张图就按几)。

限制

  • 缩略图是用彩色半格字符(▀)拼出来的,一个字符格显示上下两个像素,能看出是哪张图,看不清图里的字。要看清楚请点「打开原图」。终端需要支持 24 位真彩色。
  • 只有 PNG 有缩略图。超过 4MB 的图或其他格式不显示缩略图,会在原位置写明原因,「打开原图」按钮照样能用。
  • 「打开原图」在 Windows 上调用 explorer.exe,在 macOS 上调用 open,在 Linux 上调用 xdg-open。作者只在 Windows 上实测过。
  • mod 是从 Claude Code 存放粘贴图片的缓存目录读原图的(<临时目录>/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)。


English

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.

Install

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.

Use

  • Paste an image. Before you send, the band above the prompt shows [Image #N] width×height, the open button, and the thumbnail.
  • Up to 4 images side by side; more are counted.
  • Delete [Image #N] from the draft and its thumbnail goes; sending the prompt clears them all.
  • Open the original: click the button, or press Ctrl+X then Tab to focus the band and press 1–4.

Limits

  • Thumbnails are drawn with half-block characters (two pixels per cell) and need a 24-bit color terminal. You can tell which image it is, but not read text in it; open the original for that.
  • Thumbnails are for PNG only. Images over 4 MB or in other formats show the reason instead, and the open button still works.
  • Opening uses explorer.exe on Windows, open on macOS, xdg-open on Linux; only tested on Windows.
  • Images are read from Claude Code's paste cache (<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.
  • Only the draft's images are shown, not the ones in earlier messages.

Develop

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).

License

MIT

Source 3 files
hooks/register.tsx 184 lines
1import { 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}
184
hooks/png.ts 457 lines
1// 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}
457
types/index.d.ts 9 lines
1// 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