SLOPSHOPPER

image-preview

Pasted images become chips you can click to preview, in the transcript and above the prompt

newbandrowstoastpromptprocess
v0.2.0no licenseupdated 2026-10-07seanmars/my-agent-plugins/plugins/image-preview
A shopper browsing a rack in a slop shop
README

image-preview

把貼上的圖片變成可點擊的 chip. 點擊後在 chip 下方跳出小 preview 卡片. 卡片右上角的 ↗ 會用系統的圖片檢視器開啟原圖.

> [Image #1] 這張圖哪裡怪怪的?
  ⎿ [ Image #1 ]
    ╭──────────────────────────────────────────────────╮
    │ Image #1 · 2433×1600                        ↗ ✕ │
    │ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
    │ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
    ╰──────────────────────────────────────────────────╯

行為

位置作用
transcript帶圖片的 prompt 下方出現 [ Image #N ] chip
input 欄位貼上後約 200ms, 正上方的 band 出現 Pasted [ Image #N ] chip; [Image #N] 文字在下一次按鍵後加上底線與顏色
chip點擊開關 hint 卡片, 一次只開一張
↗用系統的圖片檢視器開啟原圖
✕關閉卡片

運作方式

  • 草稿的圖片: 讀取 Claude Code 為每次貼上快取的檔案 <temp>/<project>/<session>/images/<n>.<ext>. <temp> 是 temp 根目錄下的 claude (Windows) 或 claude-<uid> (macOS 和 Linux). temp 根目錄是 CLAUDE_CODE_TMPDIR, 沒有設定時是系統的 temp 資料夾. 這個檔案就是實際附加的圖片, 所以從檔案拖曳的圖片也正確.
  • 偵測草稿: 貼上圖片不會觸發 prompt.edit, 所以每 200ms 用 $.prompt.read() 讀一次輸入框. 只有互動模式的 terminal session 才會啟動這個 timer.
  • 已送出的圖片: 圖片內容取自 session.append 的 image block. 編號取自 transcript 該列的 imagePasteIds, 所以手打的 [Image #N] 不會讓編號標錯. transcript 寫入之前, 先用文字中的標記暫時命名. 取得編號後, session state 改存快取檔案的路徑, 不再保存 base64. transcript 路徑取自 classic.UserPromptSubmit 和 classic.SessionStart.
  • 解碼: scripts/image.py 用 Pillow 解碼 PNG, JPEG, GIF (第一格), WebP 和 BMP, 依照 EXIF orientation 轉正, 並縮到 400px 以內. 解碼結果最多保留 8 張. scripts/paste_ids.py 從 transcript 檔尾往回讀取 imagePasteIds, 不需要 Pillow. 所有腳本都由 uv run --script 執行, 依賴宣告在腳本內 (PEP 723).
  • 繪製: 使用 Raster, 每格一個 ▀, 前景色和背景色各代表一個 pixel. 這個方法不需要 kitty graphics protocol, 所以 Windows Terminal 和 VS Code terminal 也能顯示. 支援 24-bit color 的 terminal 顯示效果最好.
  • 畫質: 縮小後的圖片用 unsharp mask 銳化邊緣. 卡片最大 64×16 cells, 不超過 Raster 一次能精確繪製的 1024 組前景色和背景色組合. 計算結果依尺寸快取.
  • 外部檢視器: scripts/open_image.py 不需要額外套件. 它在 Windows 用檔案的預設程式開啟, 在 macOS 用 open, 在 Linux 用 xdg-open. 沒有快取檔案的圖片先寫到系統 temp 資料夾下的 claude-image-preview.

限制

  • 只支援 terminal surface. 點擊 transcript 內的 chip 需要 fullscreen layout.
  • 如果手打一個這個 session 貼過的舊編號, 草稿 preview 會顯示那張舊圖, 因為快取裡還有那個檔案. 送出後的 transcript 不受影響.
  • 從 --resume 載入的舊訊息沒有 chip, 因為載入不會觸發 session.append.
  • macOS 的快取路徑只在 test kit 中驗證過, 還沒有實機測試.

需求

  • Claude Code ≥ 2.1.291
  • CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
  • uv. 第一次解碼時, uv 會下載 Pillow, 需要網路.

開發

claude --plugin-dir ./plugins/image-preview --debug   # 存檔即 hot-reload
claude plugin validate ./plugins/image-preview
claude plugin test ./plugins/image-preview
uv run --script ./plugins/image-preview/scripts/image.py decode --path <image> | head -1
uv run --script ./plugins/image-preview/scripts/paste_ids.py --path <transcript.jsonl> --uuid <row uuid>
uv run --script ./plugins/image-preview/scripts/open_image.py --path <image>

prompt.edit 在 2.1.291 的 test kit 中無法觸發, 所以 [Image #N] 的上色需要手動測試.

Source 4 files
hooks/register.tsx 523 lines
1/**
2 * image-preview: every pasted image becomes a chip that opens a preview.
3 *
4 * Chips sit under each prompt in the transcript that carried images, and in
5 * the band above the prompt for the `[Image #N]` placeholders in the draft.
6 * A click opens a small card under the chip, scaled to keep the picture's
7 * shape; the card's ↗ opens the whole picture in the system's own viewer.
8 *
9 * Pixels come from scripts/image.py (Pillow, run by uv), which decodes and
10 * scales an image. A draft's image is the file Claude Code cached for that
11 * paste, `<claude temp>/<project>/<session>/images/<n>.<ext>`. A sent
12 * prompt's images come whole from `session.append`, their paste numbers from
13 * the `imagePasteIds` its transcript line keeps; once those are known, the
14 * cached files stand in for the bytes.
15 *
16 * Everything that takes `$` is a top-level declaration: the loader inventories
17 * what a hooks module reaches for through `$`.
18 */
19import { atom, memberOf, read, update } from 'claude-code'
20import type { Args, EngineInterface, Register, RenderComponent, RenderElement, Timer } from 'claude-code'
21
22import type { DecodeSource, ImageData, ImageRef } from '../types'
23import { fitCells, parseDecoded, rasterCells } from './pixels'
24import type { CellSize, Decoded } from './pixels'
25import { imageNumbers, placeholders } from './placeholders'
26
27const ACCENT = 'suggestion'
28/**
29 * The card's picture at most; it shrinks to keep the shape, and to fit. Its
30 * 1024 cells are no more color pairs than a Raster paints as given.
31 */
32const HINT_ROOM: CellSize = { columns: 64, rows: 16 }
33/** A card's border, padding and indent around its picture. */
34const CARD_CHROME = 6
35/** The card's header needs this much even beside a narrow picture. */
36const CARD_MIN_COLUMNS = 24
37/** The decoder scales a picture down to fit this many pixels a side. */
38const DECODE_MAX = 400
39/** Decoded pictures held at most. */
40const DECODED_KEPT = 8
41/** Pasting an image raises no `prompt.edit`, so the draft is read on a timer. */
42const POLL_MS = 200
43/** A sent prompt's transcript line is written a moment after its row: look again this often, this many times. */
44const RELABEL_MS = 500
45const RELABEL_TRIES = 10
46/** Claude Code's temp folder under a temp root: `claude` on Windows, `claude-<uid>` elsewhere. */
47const CLAUDE_TEMP = /^claude(-\d+)?$/
48/** A file of the paste cache: `<paste number>.<ext>`. */
49const PASTED_FILE = /^(\d+)\.[A-Za-z0-9]+$/
50/**
51 * The helpers declare their own dependencies, Pillow for the decoder and none
52 * for the paste numbers; `--no-project` keeps them clear of the session's.
53 */
54const UV = ['uv', 'run', '--quiet', '--no-project', '--script']
55
56const MESSAGE_IMAGES = { plugin: 'image-preview', key: 'messageImages' } as const
57const IMAGE_SOURCE = { plugin: 'image-preview', key: 'imageSource' } as const
58const draftImages = atom({ plugin: 'image-preview', key: 'draftImages' } as const, [])
59const hint = atom({ plugin: 'image-preview', key: 'hint' } as const, null)
60
61/**
62 * Decoded pixels by image id, the last drawn last; past DECODED_KEPT the
63 * oldest goes. A reload starts it over; the next draw decodes again.
64 */
65const decoded = new Map<string, Promise<Decoded>>()
66/** Ids whose decode failed: the next click on the chip tries again. */
67const failed = new Set<string>()
68/** The cells worked out for a picture, by size: a redraw at a size already drawn reuses them. */
69const drawnCells = new WeakMap<Decoded, Map<string, string>>()
70/** Sizes kept for one picture: the card's, as the terminal is resized. */
71const SIZES_KEPT = 4
72/** The draft's image numbers as last written; unset after a reload, so the first read writes. */
73let draftNumbers: string | undefined
74let isReadingDraft = false
75/** The timer that reads the draft: one per module, whatever starts it again. */
76let draftPoll: Timer | undefined
77/** Where this session's paste cache and transcript are, found once per session id. */
78let pasteCache: { sessionId: string; dir: string } | undefined
79let transcript: { sessionId: string; path: string } | undefined
80
81/** A drawing that holds chips; its `requestId` says which chip's card is open. */
82type Site = { surface: 'terminal'; component: RenderComponent; requestId: string }
83type Preview = { decoded: Decoded } | { problem: string }
84type ContentBlock = Args<'session.append'>['message']['content'][number]
85
86const draftId = (number: number) => `draft:${number}`
87const draftNumber = (imageId: string) => (imageId.startsWith('draft:') ? Number(imageId.slice(6)) : undefined)
88const helperPath = ($: EngineInterface, script: string) => `${$.plugin.root}/scripts/${script}`
89const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
90
91function cardRoom(columns: number, rows: number): CellSize {
92  return {
93    columns: Math.max(1, Math.min(HINT_ROOM.columns, columns - CARD_CHROME)),
94    rows: Math.max(1, Math.min(HINT_ROOM.rows, rows)),
95  }
96}
97
98function caption(image: ImageRef, preview: Preview): string {
99  if (!('decoded' in preview)) return image.label
100  return `${image.label} · ${preview.decoded.originalWidth}×${preview.decoded.originalHeight}`
101}
102
103/** An image block's bytes, when the block is one the engine holds inline. */
104function imageData(block: ContentBlock): ImageData[] {
105  const source = block.source as { type?: unknown; media_type?: unknown; data?: unknown } | undefined
106  if (block.type !== 'image' || source?.type !== 'base64' || typeof source.data !== 'string') return []
107  const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image'
108  return [{ mediaType, base64: source.data }]
109}
110
111function textOf(block: ContentBlock): string {
112  return block.type === 'text' && typeof block.text === 'string' ? block.text : ''
113}
114
115// ── Claude Code's files ─────────────────────────────────────────────────────
116
117/**
118 * Where Claude Code's temp folder may be: `claude` on Windows, `claude-<uid>`
119 * on macOS and Linux, under `CLAUDE_CODE_TMPDIR` when it is set, else under
120 * the system's temp folder. Each is found by its name, since the session
121 * folder under it tells which one is this person's.
122 */
123async function claudeTemps($: EngineInterface): Promise<string[]> {
124  const custom = await $.env.get('CLAUDE_CODE_TMPDIR')
125  const system = [await $.env.get('TEMP'), await $.env.get('TMP'), await $.env.get('TMPDIR'), '/tmp']
126  const bases = (custom ? [custom] : system).flatMap((base) => (base ? [base.replace(/[\\/]+$/, '')] : []))
127
128  const temps: string[] = []
129  for (const base of new Set(bases)) {
130    for (const entry of await $.fs.list(base).catch(() => [])) {
131      if (entry.kind === 'dir' && CLAUDE_TEMP.test(entry.name)) temps.push(`${base}/${entry.name}`)
132    }
133  }
134  return temps
135}
136
137/**
138 * `tail` under the one project folder of `root` that holds it. A project
139 * folder is named after a working directory that may have moved since, so
140 * this session's is found by what it holds instead of rebuilt.
141 */
142async function underProject($: EngineInterface, root: string, tail: string): Promise<string | undefined> {
143  for (const entry of await $.fs.list(root).catch(() => [])) {
144    const path = `${root}/${entry.name}/${tail}`
145    if (entry.kind === 'dir' && (await $.fs.exists(path))) return path
146  }
147  return undefined
148}
149
150async function pasteCacheDir($: EngineInterface): Promise<string | undefined> {
151  const sessionId = await $.session.id()
152  if (pasteCache?.sessionId === sessionId) return pasteCache.dir
153  for (const root of await claudeTemps($)) {
154    const dir = await underProject($, root, `${sessionId}/images`)
155    if (dir !== undefined) {
156      pasteCache = { sessionId, dir }
157      return dir
158    }
159  }
160  return undefined
161}
162
163/** The files Claude Code cached for this session's pastes, by paste number, whatever their extension. */
164async function pastedFiles($: EngineInterface): Promise<Map<number, string>> {
165  const files = new Map<number, string>()
166  const dir = await pasteCacheDir($)
167  if (dir === undefined) return files
168  for (const entry of await $.fs.list(dir).catch(() => [])) {
169    const paste = entry.kind === 'file' ? PASTED_FILE.exec(entry.name) : null
170    if (paste) files.set(Number(paste[1]), `${dir}/${entry.name}`)
171  }
172  return files
173}
174
175/** The transcript a classic event names: each prompt, and each session that starts. */
176function noteTranscript(e: { session_id: string; transcript_path: string }): void {
177  if (e.transcript_path) transcript = { sessionId: e.session_id, path: e.transcript_path }
178}
179
180/**
181 * The session's transcript, as a classic event named it; after a reload,
182 * until the next prompt names it, found by its name under the config folder.
183 */
184async function transcriptPath($: EngineInterface): Promise<string | undefined> {
185  const sessionId = await $.session.id()
186  if (transcript?.sessionId === sessionId) return transcript.path
187  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
188  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
189  const path = config === undefined ? undefined : await underProject($, `${config}/projects`, `${sessionId}.jsonl`)
190  if (path !== undefined) transcript = { sessionId, path }
191  return path
192}
193
194// ── Pixels ──────────────────────────────────────────────────────────────────
195
196async function runDecoder($: EngineInterface, source: DecodeSource): Promise<Decoded> {
197  // The first run also has uv fetch Pillow, so it is given the longer wait.
198  const argv = [...UV, helperPath($, 'image.py'), 'decode', '--max', String(DECODE_MAX)]
199  const result =
200    'path' in source
201      ? await $.process.run([...argv, '--path', source.path], { timeoutMs: 120_000 })
202      : await $.process.run(argv, { stdin: source.base64, timeoutMs: 120_000 })
203  if (result.exitCode !== 0) {
204    const what = 'path' in source ? source.path.split('/').pop() : source.mediaType
205    throw new Error(`Cannot decode ${what}: ${firstLine(result.stderr)}`)
206  }
207  return parseDecoded(result.stdout)
208}
209
210/** Where an image's bytes are: the paste cache for a draft's, state for a sent one's. */
211async function sourceOf($: EngineInterface, imageId: string): Promise<DecodeSource> {
212  const number = draftNumber(imageId)
213  if (number !== undefined) {
214    const path = (await pastedFiles($)).get(number)
215    if (path === undefined) throw new Error(`Claude Code holds no pasted image #${number} in this session.`)
216    return { path }
217  }
218  const { value: source } = await $.state.get({ ...IMAGE_SOURCE, id: imageId })
219  if (!source) throw new Error('This image is no longer held.')
220  return source
221}
222
223function forgetDecoded(imageId: string): void {
224  decoded.delete(imageId)
225  failed.delete(imageId)
226}
227
228/**
229 * The image's pixels, decoded once. The promise is kept before anything is
230 * awaited, so a second drawing of the same image waits on the first decode.
231 */
232function decodeImage($: EngineInterface, imageId: string): Promise<Decoded> {
233  const known = decoded.get(imageId)
234  const pending = known ?? sourceOf($, imageId).then((source) => runDecoder($, source))
235  decoded.delete(imageId)
236  decoded.set(imageId, pending)
237  if (!known) pending.catch(() => failed.add(imageId))
238
239  for (const oldest of decoded.keys()) {
240    if (decoded.size <= DECODED_KEPT) break
241    forgetDecoded(oldest)
242  }
243  return pending
244}
245
246/**
247 * A draft's `[Image #N]` names a paste of the session it is in: once another
248 * session starts, the pixels decoded for it are another session's picture.
249 */
250function forgetDrafts(): void {
251  for (const imageId of decoded.keys()) {
252    if (draftNumber(imageId) !== undefined) forgetDecoded(imageId)
253  }
254  // Unset, so the next tick writes the chips for the new session.
255  draftNumbers = undefined
256}
257
258/** Raster cells for the picture at `size`, worked out once per size. */
259function cellsFor(picture: Decoded, size: CellSize): string {
260  const key = `${size.columns}x${size.rows}`
261  const sizes = drawnCells.get(picture) ?? new Map<string, string>()
262  drawnCells.set(picture, sizes)
263
264  const cells = sizes.get(key) ?? rasterCells(picture, size)
265  sizes.delete(key)
266  sizes.set(key, cells)
267  for (const oldest of sizes.keys()) {
268    if (sizes.size <= SIZES_KEPT) break
269    sizes.delete(oldest)
270  }
271  return cells
272}
273
274async function loadPreview($: EngineInterface, imageId: string): Promise<Preview> {
275  try {
276    return { decoded: await decodeImage($, imageId) }
277  } catch (error) {
278    return { problem: error instanceof Error ? error.message : String(error) }
279  }
280}
281
282// ── Sent prompts ────────────────────────────────────────────────────────────
283
284async function rememberImages($: EngineInterface, uuid: string, content: readonly ContentBlock[]): Promise<void> {
285  const pictures = content.flatMap(imageData)
286  if (pictures.length === 0) return
287
288  // Until the transcript line says which pastes these are, the tags in the
289  // text name them when they match the images one to one.
290  const numbers = imageNumbers(content.map(textOf).join('\n'))
291  const images = pictures.map((_, index) => ({
292    id: `${uuid}:${index}`,
293    label: numbers.length === pictures.length ? `Image #${numbers[index]}` : `Image ${index + 1}`,
294  }))
295
296  // Held until the transcript line names the pastes; `keepPastes` lets them go.
297  for (const [index, image] of images.entries()) {
298    const picture = pictures[index]
299    if (picture) await $.state.set({ ...IMAGE_SOURCE, id: image.id }, picture)
300  }
301  await $.state.set({ ...MESSAGE_IMAGES, id: uuid }, images)
302  $.clock.after(RELABEL_MS, () => void relabel($, uuid, RELABEL_TRIES))
303}
304
305/** The paste numbers row `uuid`'s transcript line keeps; null until the line is written. */
306async function pasteIds($: EngineInterface, uuid: string): Promise<number[] | null> {
307  const path = await transcriptPath($)
308  if (path === undefined) return null
309  const argv = [...UV, helperPath($, 'paste_ids.py'), '--path', path, '--uuid', uuid]
310  const result = await $.process.run(argv, { timeoutMs: 30_000 })
311  if (result.exitCode === 4) return []
312  if (result.exitCode !== 0) return null
313  return result.stdout.trim().split(',').filter(Boolean).map(Number)
314}
315
316/**
317 * Names each image by the paste it came from: `imagePasteIds` keeps one
318 * number per image block, in order. A typed `[Image #1]` reads the same as a
319 * pasted one, so the tags in the text alone can misname them.
320 */
321async function relabel($: EngineInterface, uuid: string, tries: number): Promise<void> {
322  try {
323    const ids = await pasteIds($, uuid)
324    if (ids === null) {
325      if (tries > 1) $.clock.after(RELABEL_MS, () => void relabel($, uuid, tries - 1))
326      return
327    }
328    const { value: images } = await $.state.get({ ...MESSAGE_IMAGES, id: uuid })
329    if (!images || ids.length !== images.length) return
330    const labeled = images.map((image, index) => ({ ...image, label: `Image #${ids[index]}` }))
331    await $.state.set({ ...MESSAGE_IMAGES, id: uuid }, labeled)
332    await keepPastes($, images, ids)
333  } catch (error) {
334    $.ui.log(`image-preview: reading paste numbers failed: ${String(error)}`, { to: 'debug' })
335  }
336}
337
338/**
339 * Swaps each image's bytes for the file Claude Code cached for its paste: the
340 * session then holds a path, not megabytes. Bytes with no file stay.
341 */
342async function keepPastes($: EngineInterface, images: ImageRef[], ids: number[]): Promise<void> {
343  const files = await pastedFiles($)
344  for (const [index, image] of images.entries()) {
345    const path = files.get(ids[index] ?? -1)
346    if (path !== undefined) await $.state.set({ ...IMAGE_SOURCE, id: image.id }, { path })
347  }
348}
349
350// ── The draft ───────────────────────────────────────────────────────────────
351
352async function syncDraft($: EngineInterface, text: string): Promise<void> {
353  const numbers = imageNumbers(text)
354  const key = numbers.join(',')
355  if (key === draftNumbers) return
356
357  await update($, draftImages, () => numbers.map((number) => ({ id: draftId(number), label: `Image #${number}` })))
358  // A card left open for a tag the draft no longer holds would reopen with it.
359  await update($, hint, (open) => {
360    const number = open === null ? undefined : draftNumber(open.imageId)
361    return number !== undefined && !numbers.includes(number) ? null : open
362  })
363  // Kept once both writes landed: after a failed one the next tick writes again.
364  draftNumbers = key
365}
366
367async function readDraft($: EngineInterface): Promise<void> {
368  if (isReadingDraft) return
369  isReadingDraft = true
370  try {
371    await syncDraft($, (await $.prompt.read()).text)
372  } catch {
373    // The next tick reads it again.
374  } finally {
375    isReadingDraft = false
376  }
377}
378
379// ── Presses ─────────────────────────────────────────────────────────────────
380
381async function toggleHint($: EngineInterface, site: string, imageId: string): Promise<void> {
382  if (failed.delete(imageId)) decoded.delete(imageId)
383  await update($, hint, (open) => (open?.site === site && open.imageId === imageId ? null : { site, imageId }))
384}
385
386async function closeHint($: EngineInterface): Promise<void> {
387  await update($, hint, () => null)
388}
389
390/** Opens the whole picture in the system's own viewer, which shows what cells cannot. */
391async function openOutside($: EngineInterface, image: ImageRef): Promise<void> {
392  try {
393    const source = await sourceOf($, image.id)
394    const argv = [...UV, helperPath($, 'open_image.py')]
395    const result =
396      'path' in source
397        ? await $.process.run([...argv, '--path', source.path], { timeoutMs: 30_000 })
398        : await $.process.run([...argv, '--media-type', source.mediaType], { stdin: source.base64, timeoutMs: 30_000 })
399    if (result.exitCode !== 0) $.ui.toast(`Cannot open ${image.label}: ${firstLine(result.stderr)}`)
400  } catch (error) {
401    $.ui.toast(`Cannot open ${image.label}: ${error instanceof Error ? error.message : String(error)}`)
402  }
403}
404
405// ── Drawing ─────────────────────────────────────────────────────────────────
406
407async function hintCard($: EngineInterface, site: Site, image: ImageRef, room: CellSize): Promise<RenderElement> {
408  const { Box, Text, Button, Raster } = $.ui.resolve(site)
409  const preview = await loadPreview($, image.id)
410  const size = 'decoded' in preview ? fitCells(preview.decoded.width, preview.decoded.height, room) : room
411  const inner = Math.max(size.columns, CARD_MIN_COLUMNS)
412
413  return (
414    <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1} width={inner + 4} marginLeft={2}>
415      <Box flexDirection="row" justifyContent="space-between">
416        <Text bold wrap="truncate-end">{caption(image, preview)}</Text>
417        <Box flexDirection="row" gap={1}>
418          <Button key={`outside:${image.id}`} label="↗" plain onPress={() => openOutside($, image)} />
419          <Button key={`close:${image.id}`} label="✕" plain onPress={() => closeHint($)} />
420        </Box>
421      </Box>
422      {'decoded' in preview ? (
423        <Box justifyContent="center">
424          <Raster key={`hint:${image.id}`} columns={size.columns} rows={size.rows} cells={cellsFor(preview.decoded, size)} />
425        </Box>
426      ) : (
427        <Text dimColor wrap="wrap">{preview.problem}</Text>
428      )}
429    </Box>
430  )
431}
432
433async function chipStrip($: EngineInterface, site: Site, images: ImageRef[], lead: string, room: CellSize): Promise<RenderElement> {
434  const { Box, Text, Button } = $.ui.resolve(site)
435  const open = await read($, hint)
436  const shown = open?.site === site.requestId ? images.find((image) => image.id === open.imageId) : undefined
437  const card = shown ? await hintCard($, site, shown, room) : null
438
439  return (
440    <Box flexDirection="column">
441      <Box flexDirection="row" gap={1}>
442        <Text dimColor>{lead}</Text>
443        {images.map((image) => (
444          <Button
445            key={`chip:${image.id}`}
446            label={image.label}
447            variant={image === shown ? 'primary' : undefined}
448            onPress={() => toggleHint($, site.requestId, image.id)}
449          />
450        ))}
451      </Box>
452      {card}
453    </Box>
454  )
455}
456
457export const register: Register = (on) => {
458  on('session.start', async ($, e, next) => {
459    // A draft is a person's at the terminal prompt, and the band is drawn there alone.
460    if (e.isInteractive && e.surface === 'terminal') draftPoll ??= $.clock.every(POLL_MS, () => void readDraft($))
461    return next(e)
462  })
463
464  // `/clear`, `/resume` and a fork switch sessions without another `session.start`.
465  on('classic.SessionStart', async ($, e, next) => {
466    forgetDrafts()
467    noteTranscript(e)
468    return next(e)
469  }).catch(($, e, next) => next(e))
470
471  // Raised before the prompt's row is appended, so its relabel knows the transcript.
472  on('classic.UserPromptSubmit', async ($, e, next) => {
473    noteTranscript(e)
474    return next(e)
475  }).catch(($, e, next) => next(e))
476
477  // Kept by the row's uuid before the row is stored, as the event allows; a
478  // failure here never keeps the row from being stored.
479  on('session.append', { door: 'prompt' }, async ($, e, next) => {
480    if (e.agentId === undefined) await rememberImages($, e.uuid, e.message.content)
481    return next(e)
482  }).catch(($, e, next) => next(e))
483
484  // Paint only: the draft's chips come from the timer, since a paste raises no edit.
485  on('prompt.edit', async ($, e, next) => {
486    const box = await next(e)
487    const marks = placeholders(box.text).map(({ start, end }) => ({ start, end, color: ACCENT, underline: true }))
488    if (marks.length === 0) return box
489    return { ...box, decorations: [...(box.decorations ?? []), ...marks] }
490  }).catch(($, e, next) => next(e))
491
492  on('ui.render', { component: 'UserMessage', surface: 'terminal' }, async ($, e, next) => {
493    const images = await read($, memberOf(MESSAGE_IMAGES, e))
494    if (!images?.length) return next(e)
495
496    const { Box } = $.ui.resolve(e)
497    const drawn = await next(e)
498    const strip = await chipStrip($, e, images, '⎿', cardRoom((e.viewport?.columns ?? 80) - 2, HINT_ROOM.rows))
499    return (
500      <Box flexDirection="column">
501        {drawn}
502        <Box marginLeft={2}>{strip}</Box>
503      </Box>
504    )
505  })
506
507  on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
508    const images = await read($, draftImages)
509    if (e.props.hasSurvey || images.length === 0) return next(e)
510
511    const { Box } = $.ui.resolve(e)
512    const below = await next(e)
513    // The chip row, the card's border and its header take four of the rows.
514    const strip = await chipStrip($, e, images, 'Pasted', cardRoom(e.props.bodyColumns, e.props.maxRows - 4))
515    return (
516      <Box flexDirection="column">
517        {below}
518        {strip}
519      </Box>
520    )
521  })
522}
523
hooks/pixels.ts 162 lines
1/**
2 * Pixels to terminal cells. Pure: no `$`, so the tests call it directly.
3 *
4 * A Raster cell is one `▀`: its foreground paints the upper pixel, its
5 * background the lower one. A cell is about twice as tall as it is wide, so a
6 * cell holds one square pixel above another and the picture keeps its shape.
7 */
8
9/** What scripts/image.py decoded: RGBA pixels, 4 bytes each, row-major. */
10export type Decoded = {
11  width: number
12  height: number
13  originalWidth: number
14  originalHeight: number
15  rgba: Uint8Array
16}
17
18export type CellSize = { columns: number; rows: number }
19
20const UPPER_HALF = 0x2580
21/** Raster's "terminal default" color: what a transparent pixel shows. */
22const DEFAULT_COLOR = 0x01000000
23/** How far sharpening moves a color away from its neighbours' mean, as a share of the difference. */
24const SHARPEN = 0.5
25
26/** Reads the decode helper's output: a size line, then base64 pixels. */
27export function parseDecoded(stdout: string): Decoded {
28  const newline = stdout.indexOf('\n')
29  const sizes = stdout.slice(0, newline).trim().split(' ').map(Number)
30  const [originalWidth = 0, originalHeight = 0, width = 0, height = 0] = sizes
31  const rgba = Uint8Array.fromBase64(stdout.slice(newline + 1).trim())
32
33  if (newline < 0 || width < 1 || height < 1 || rgba.length !== width * height * 4) {
34    throw new Error('the decoder answered something that is not an image')
35  }
36  return { width, height, originalWidth, originalHeight, rgba }
37}
38
39/** The largest box of cells inside the room that keeps the picture's shape. */
40export function fitCells(width: number, height: number, room: CellSize): CellSize {
41  const scale = Math.min(room.columns / width, (room.rows * 2) / height)
42  return {
43    columns: clamp(Math.round(width * scale), 1, room.columns),
44    rows: clamp(Math.round((height * scale) / 2), 1, room.rows),
45  }
46}
47
48/**
49 * Raster's `cells` for the picture scaled into `size`. A picture scaled down
50 * has its edges sharpened again.
51 */
52export function rasterCells(image: Decoded, size: CellSize): string {
53  const { columns, rows } = size
54  const scaled = scalePixels(image, columns, rows * 2)
55  const pixels = image.width > columns ? sharpen(scaled, columns, rows * 2) : scaled
56
57  const words = new Uint32Array(columns * rows * 3)
58  for (let row = 0; row < rows; row++) {
59    for (let column = 0; column < columns; column++) {
60      const at = (row * columns + column) * 3
61      words[at] = UPPER_HALF
62      words[at + 1] = pixels[row * 2 * columns + column] ?? DEFAULT_COLOR
63      words[at + 2] = pixels[(row * 2 + 1) * columns + column] ?? DEFAULT_COLOR
64    }
65  }
66  return new Uint8Array(words.buffer).toBase64()
67}
68
69/** The picture as `width` by `height` colors, row-major. */
70function scalePixels(image: Decoded, width: number, height: number): Uint32Array {
71  const pixels = new Uint32Array(width * height)
72  for (let y = 0; y < height; y++) {
73    for (let x = 0; x < width; x++) pixels[y * width + x] = averageColor(image, x, y, width, height)
74  }
75  return pixels
76}
77
78/**
79 * An unsharp mask: each color moves away from the mean of its 3 x 3
80 * neighbours by SHARPEN of the difference. Transparent pixels stay, and take
81 * no part in the mean.
82 */
83function sharpen(pixels: Uint32Array, width: number, height: number): Uint32Array {
84  const sharpened = new Uint32Array(pixels.length)
85  for (let y = 0; y < height; y++) {
86    for (let x = 0; x < width; x++) {
87      const color = pixels[y * width + x] ?? DEFAULT_COLOR
88      if (color === DEFAULT_COLOR) {
89        sharpened[y * width + x] = color
90        continue
91      }
92      const mean = [0, 0, 0]
93      let count = 0
94      for (let ny = Math.max(0, y - 1); ny <= Math.min(height - 1, y + 1); ny++) {
95        for (let nx = Math.max(0, x - 1); nx <= Math.min(width - 1, x + 1); nx++) {
96          const near = pixels[ny * width + nx] ?? DEFAULT_COLOR
97          if (near === DEFAULT_COLOR) continue
98          for (const [channel, value] of rgbOf(near).entries()) mean[channel] = (mean[channel] ?? 0) + value
99          count += 1
100        }
101      }
102      const moved = rgbOf(color).map((value, channel) => value + SHARPEN * (value - (mean[channel] ?? 0) / count))
103      sharpened[y * width + x] = colorOf(moved)
104    }
105  }
106  return sharpened
107}
108
109function rgbOf(color: number): number[] {
110  return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]
111}
112
113function colorOf([red = 0, green = 0, blue = 0]: number[]): number {
114  const channel = (value: number) => clamp(Math.round(value), 0, 255)
115  return (channel(red) << 16) | (channel(green) << 8) | channel(blue)
116}
117
118/**
119 * The mean color of the source pixels under target pixel (x, y) of a
120 * `targetWidth` by `targetHeight` grid, weighted by alpha.
121 */
122function averageColor(
123  image: Decoded,
124  x: number,
125  y: number,
126  targetWidth: number,
127  targetHeight: number,
128): number {
129  const [left, right] = span(x, targetWidth, image.width)
130  const [top, bottom] = span(y, targetHeight, image.height)
131  let red = 0
132  let green = 0
133  let blue = 0
134  let alpha = 0
135
136  for (let sy = top; sy < bottom; sy++) {
137    for (let sx = left; sx < right; sx++) {
138      const at = (sy * image.width + sx) * 4
139      const a = image.rgba[at + 3] ?? 0
140      red += (image.rgba[at] ?? 0) * a
141      green += (image.rgba[at + 1] ?? 0) * a
142      blue += (image.rgba[at + 2] ?? 0) * a
143      alpha += a
144    }
145  }
146
147  const count = (right - left) * (bottom - top)
148  if (alpha < count * 128) return DEFAULT_COLOR
149  return (Math.round(red / alpha) << 16) | (Math.round(green / alpha) << 8) | Math.round(blue / alpha)
150}
151
152/** The source range [start, end) that target index `i` of `target` covers. */
153function span(i: number, target: number, source: number): [number, number] {
154  const start = Math.min(source - 1, Math.floor((i * source) / target))
155  const end = Math.max(start + 1, Math.floor(((i + 1) * source) / target))
156  return [start, Math.min(end, source)]
157}
158
159function clamp(value: number, low: number, high: number): number {
160  return Math.max(low, Math.min(high, value))
161}
162
hooks/placeholders.ts 19 lines
1/** Where Claude Code wrote `[Image #N]` for a pasted image. */
2export type Placeholder = { number: number; start: number; end: number }
3
4const PLACEHOLDER = /\[Image #(\d+)\]/g
5
6/** Every `[Image #N]` in the text, in order, repeats included. */
7export function placeholders(text: string): Placeholder[] {
8  return [...text.matchAll(PLACEHOLDER)].map((match) => ({
9    number: Number(match[1]),
10    start: match.index,
11    end: match.index + match[0].length,
12  }))
13}
14
15/** The image numbers the text names, each once, in order. */
16export function imageNumbers(text: string): number[] {
17  return [...new Set(placeholders(text).map((found) => found.number))]
18}
19
types/index.d.ts 41 lines
1/** One image a chip stands for. */
2export type ImageRef = {
3  /** `<message uuid>:<index>` for a sent image, `draft:<n>` for paste `n` in the draft. */
4  id: string
5  /** What the chip reads: `Image #1`. */
6  label: string
7}
8
9/** A sent image's bytes, as the transcript row carried them. */
10export type ImageData = {
11  mediaType: string
12  base64: string
13}
14
15/**
16 * Where the decoder reads an image: a file Claude Code cached for a paste,
17 * or bytes the transcript row carried, kept only while no file is known.
18 */
19export type DecodeSource = { path: string } | ImageData
20
21/** The chip whose card is open: the drawing that holds it and its image. */
22export type OpenHint = {
23  site: string
24  imageId: string
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'image-preview': {
30      /** The images a transcript message carried, by the message's uuid. */
31      messageImages: StateFamily<ImageRef[]>
32      /** The `[Image #N]` placeholders in the prompt draft, in order. */
33      draftImages: ImageRef[]
34      /** Where each sent image is read from, by its id. */
35      imageSource: StateFamily<DecodeSource>
36      /** One card at a time, under the chip that opened it. */
37      hint: OpenHint | null
38    }
39  }
40}
41