SLOPSHOPPER

image-view

See the images you paste into Claude Code: thumbnails above the prompt instead of bare [Image #1] tags

newbandrowsguardprocesstimer
A shopper browsing a rack in a slop shop
README

Claude Image View

Local copy of jarrodwatts/claude-image-view at b3c412b, managed by nixconfig. Three changes:

  • Tiles also show in the transcript: under each sent prompt with [Image #n] tags, and under each image Claude reads with Read (a "Read image" row, or a folded "Read N files" line), sends you with SendUserFile or SendUserMessage (a "› [image] …" row), or gets back from an MCP tool (an argent screenshot, say: the file Claude Code saves each image block to). A JPEG, GIF or WebP is shown from a PNG copy sips makes, since Image only reads PNG files.
  • In Zed's terminal (TERM_PROGRAM=zed), which has no graphics protocol, tiles are drawn as half-block pixel art (hooks/halfblock.ts) instead of the alt text. So are they in a background session (CLAUDE_CODE_SESSION_KIND=bg, e.g. one you claude attach to), where Claude Code turns terminal images off, unless CLAUDE_CODE_FORCE_TERMINAL_IMAGES turns them back on. Every other terminal still gets the kitty-protocol Image.
  • Clicking a tile's #n label opens the image in macOS Quick Look, through bin/quicklook (built from quicklook/main.swift by nixconfig): no Dock icon, and on the display under the pointer. Without it, the mod falls back to qlmanage -p. The picture itself can't take a click: Claude Code's Image and Raster elements don't accept presses.

A Claude Code mod that shows the images you paste, so you see thumbnails above your prompt instead of bare [Image #1] tags.

License Stars

Claude Image View in action

Install

Inside Claude Code, run:

/plugin marketplace add jarrodwatts/claude-image-view
/plugin install image-view
/reload-plugins

That's it. Paste an image into the prompt and its thumbnail appears above the input.

claude plugin marketplace add jarrodwatts/claude-image-view
claude plugin install image-view@claude-image-view

Then run /reload-plugins inside a session, or start a new one.

What You See

Paste one or more images and a row of thumbnails sits above the prompt, each labelled with the number of its tag:

╭────────────────────────╮ ╭────────────╮
│                        │ │            │
│      (screenshot)      │ │  (photo)   │
│                        │ │            │
│           #1           │ │     #2     │
╰────────────────────────╯ ╰────────────╯
❯ why is the header misaligned here [Image #1] vs [Image #2]
  • Thumbnails appear as soon as you paste. You don't have to type another key first.
  • Thumbnails keep their shape. Wide screenshots stay wide and phone shots stay tall.
  • Always fits on screen. Tiles shrink to fit the space above the prompt, so the row never scrolls or gets cut off.
  • Clears on send. Once the prompt is sent (or the tags are deleted), the row goes away.

How It Works

Claude Code saves every pasted image to a cache folder for the session, as <tmp>/<project>/<session>/images/<n>.png, and puts an [Image #n] tag in the prompt. Claude Image View is a mod:

  1. Every 200ms it reads the prompt box and looks for [Image #n] tags. It checks on a timer because pasting an image doesn't raise an edit event.
  2. For each tag it finds the cached PNG and reads its size from the PNG header.
  3. It draws the thumbnails in the band above the prompt with Claude Code's Image element. The terminal reads the file itself, so the image data never passes through the mod.

Security

Claude Image View is local-only. It makes no network requests and, outside Zed, writes no files. In Zed it runs /usr/bin/sips to scale each pasted image into a mktemp -d folder as a BMP, and reads its pixels back. Clicking a tile's label runs bin/quicklook (or /usr/bin/qlmanage -p) on that image. It reads the prompt box, lists Claude Code's temp folder to find the current session's image cache, and reads the first bytes of each pasted image. If CLAUDE_CODE_TMPDIR isn't set, it runs id -u once to find the default temp folder.

Run claude plugin validate on the repo to see every event it hooks and every call it makes.

Requirements

  • Claude Code v2.1.287 or later (mods support)
  • macOS or Linux
  • A terminal with the kitty graphics protocol, such as Ghostty or kitty

Zed shows each picture as colored half blocks, two pixels per cell, so a thumbnail is a rough preview. Other terminals show [Image #n] in each tile instead of the picture. The Claude Desktop app already previews pasted images, so the mod draws nothing there.

Troubleshooting

Nothing appears when I paste. Run /plugin and check the dim line under the tabs lists image-view as an active mod. If it isn't listed, run /reload-plugins.

The tile says "no preview". The mod couldn't find the cached file. Claude Code may have moved where it stores pasted images. Please open an issue with your Claude Code version.

The tile shows [Image #1] text instead of the picture. Your terminal doesn't support the kitty graphics protocol. See Requirements.

The tile shows half-block pixel art in agent view or a background session, even in Ghostty or kitty. Claude Code turns terminal images off for background sessions, so the mod draws half blocks there. If you attach from a terminal with the kitty graphics protocol, turn them back on in the env block of ~/.claude/settings.json, then start a new session:

"env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" }

Development

git clone https://github.com/jarrodwatts/claude-image-view
cd claude-image-view

# Load it for one session without installing
claude --plugin-dir .

# Check it and run the tests
claude plugin validate .
claude plugin test .

Claude Code writes the API types into .claude-plugin/types/ the first time it loads the mod, and tsc -p . type-checks it from then on.

License

MIT. See LICENSE.

Star History

Star History Chart

Source 4 files
hooks/register.tsx 436 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiRenderEvent } from 'claude-code'
3
4import type { PastedImage } from '../types'
5import { halfBlockCells, parseBmp } from './halfblock'
6import { fitRow, imageNumbers, pngSize } from './layout'
7import type { Cells, Size } from './layout'
8
9// Pasting an image raises no prompt.edit (the tag only shows up on the next keystroke),
10// so the draft is polled instead.
11const POLL_MS = 200
12
13const images = atom({ plugin: 'image-view', key: 'images' } as const, [] as PastedImage[])
14
15let tmpRoot: string | undefined
16let found: { sessionId: string; dir: string } | undefined
17// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
18// while a drawn image's file is still missing, so the next poll looks again.
19let shownKey: string | undefined
20let isChecking = false
21const sizes = new Map<string, Size | null>()
22
23// A thumbnail: a pasted image (`#n`) or an image Claude read. `id` keys its
24// Image and its label's Button within one drawing.
25type Tile = { id: string; label: string; alt: string; path: string | null; size: Size | null }
26
27function pastedTile(image: PastedImage): Tile {
28  return { id: String(image.n), label: `#${image.n}`, alt: `[Image #${image.n}]`, path: image.path, size: image.size }
29}
30
31// Render hooks can't run processes, so each site records the files it needs
32// and the poll makes them with sips:
33// - where Image can't draw, a half-block Raster for each tile size, from a BMP
34//   scaled to the tile;
35// - elsewhere, a PNG copy of a JPEG, GIF or WebP, since Image reads PNG files only.
36let isHalfBlock = false
37let workDir: string | undefined
38let fileCount = 0
39type Wanted = Cells & { path: string }
40// By render site: the band above the prompt, a sent prompt, a Read result.
41const wantedThumbs = new Map<string, Wanted[]>()
42const wantedPngs = new Map<string, string[]>()
43// Raster cells by thumbKey, and PNG copies by source path; null when sips failed.
44const thumbs = new Map<string, string | null>()
45const pngs = new Map<string, string | null>()
46
47function thumbKey({ path, columns, rows }: Wanted): string {
48  return `${path}:${columns}x${rows}`
49}
50
51function isPng(path: string): boolean {
52  return /\.png$/i.test(path)
53}
54
55async function scratchFile($: EngineInterface, name: string): Promise<string> {
56  workDir ??= (await $.process.run(['mktemp', '-d'])).stdout.trim()
57  fileCount += 1
58  return `${workDir}/${fileCount}-${name}`
59}
60
61async function thumbnail($: EngineInterface, tile: Wanted): Promise<string | null> {
62  const out = await scratchFile($, `${tile.columns}x${tile.rows}.bmp`)
63  // -z takes the height first.
64  const args = ['-s', 'format', 'bmp', '-z', String(tile.rows * 2), String(tile.columns), tile.path, '--out', out]
65  const { exitCode } = await $.process.run(['/usr/bin/sips', ...args])
66  if (exitCode !== 0) return null
67  const bitmap = parseBmp((await $.fs.read(out, { as: 'bytes' })).base64)
68  return bitmap === null ? null : halfBlockCells(bitmap, tile.columns, tile.rows)
69}
70
71async function pngCopy($: EngineInterface, path: string): Promise<string | null> {
72  const out = await scratchFile($, 'copy.png')
73  const { exitCode } = await $.process.run(['/usr/bin/sips', '-s', 'format', 'png', path, '--out', out])
74  return exitCode === 0 ? out : null
75}
76
77async function convertWanted($: EngineInterface) {
78  let isNew = false
79  const tiles = [...wantedThumbs.values()].flat()
80  const tileKeys = new Set(tiles.map(thumbKey))
81  for (const key of thumbs.keys()) if (!tileKeys.has(key)) thumbs.delete(key)
82  for (const tile of tiles) {
83    const key = thumbKey(tile)
84    if (thumbs.has(key)) continue
85    thumbs.set(key, await thumbnail($, tile).catch(() => null))
86    isNew = true
87  }
88  for (const path of new Set([...wantedPngs.values()].flat())) {
89    if (pngs.has(path)) continue
90    pngs.set(path, await pngCopy($, path).catch(() => null))
91    isNew = true
92  }
93  if (isNew) $.ui.invalidate('ui.render')
94}
95
96// Both runs until the Quick Look panel closes, so the previous preview is ended
97// before the next one opens. bin/quicklook (quicklook/main.swift, built by
98// nixconfig) opens without a Dock icon on the display under the pointer;
99// `qlmanage -p` is the fallback when it isn't there.
100let preview: ReturnType<EngineInterface['process']['spawn']> | undefined
101
102async function quickLook($: EngineInterface, path: string) {
103  void preview?.return(undefined)
104  const helper = `${$.plugin.root}/bin/quicklook`
105  const argv = (await $.fs.exists(helper)) ? [helper, path] : ['/usr/bin/qlmanage', '-p', path]
106  const child = $.process.spawn({ argv })
107  preview = child
108  void (async () => {
109    try {
110      for await (const _ of child) {
111        // Drained only to keep the child alive until the panel closes.
112      }
113    } catch {
114      // Closed, replaced, or qlmanage missing: nothing to show either way.
115    } finally {
116      if (preview === child) preview = undefined
117    }
118  })()
119}
120
121// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.png. The project
122// folder is named after a working directory that may since have moved, so find it by the
123// session id instead of rebuilding it.
124async function imagesDir($: EngineInterface): Promise<string | undefined> {
125  const sessionId = await $.session.id()
126  if (found?.sessionId === sessionId) return found.dir
127  if (tmpRoot === undefined) {
128    const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
129    tmpRoot = fromEnv ?? `/tmp/claude-${(await $.process.run(['id', '-u'])).stdout.trim()}`
130  }
131  const entries = await $.fs.list(tmpRoot).catch(() => [])
132  for (const entry of entries) {
133    const dir = `${tmpRoot}/${entry.name}/${sessionId}/images`
134    if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
135      found = { sessionId, dir }
136      return dir
137    }
138  }
139  return undefined
140}
141
142async function describe($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
143  const path = `${dir}/${n}.png`
144  if (dir === undefined || !(await $.fs.exists(path))) return { n, path: null, size: null }
145  if (!sizes.has(path)) {
146    const head = await $.fs.read(path, { as: 'bytes' }).then(
147      ({ base64 }) => pngSize(base64),
148      () => undefined, // too big to read: still drawable, just without its aspect ratio
149    )
150    if (head === null) return { n, path: null, size: null }
151    sizes.set(path, head ?? null)
152  }
153  return { n, path, size: sizes.get(path) ?? null }
154}
155
156async function show($: EngineInterface, draft: string) {
157  const numbers = imageNumbers(draft)
158  const key = numbers.join(',')
159  if (key === shownKey) return
160  const dir = numbers.length > 0 ? await imagesDir($) : undefined
161  const list: PastedImage[] = []
162  for (const n of numbers) list.push(await describe($, dir, n))
163  shownKey = list.every(image => image.path !== null) ? key : undefined
164  await update($, images, () => list)
165}
166
167// A sent prompt's images, by message id, once every tag has its file.
168const messageImages = new Map<string, PastedImage[]>()
169
170async function imagesOf($: EngineInterface, messageId: string, text: string): Promise<PastedImage[]> {
171  const cached = messageImages.get(messageId)
172  if (cached) return cached
173  const numbers = imageNumbers(text)
174  if (numbers.length === 0) return []
175  const dir = await imagesDir($)
176  const list: PastedImage[] = []
177  for (const n of numbers) list.push(await describe($, dir, n))
178  if (list.every(image => image.path !== null)) messageImages.set(messageId, list)
179  return list
180}
181
182// The file each Read call opened, by tool_use_id: a Read result carries the
183// image but not its path. Filled as calls run, and from their rows on a resume.
184const readPaths = new Map<string, string>()
185
186type ReadImageOutput = {
187  type: 'image'
188  file: { dimensions?: { originalWidth?: number; originalHeight?: number; displayWidth?: number; displayHeight?: number } }
189}
190
191function isReadImage(output: unknown): output is ReadImageOutput {
192  return typeof output === 'object' && output !== null && (output as { type?: unknown }).type === 'image'
193}
194
195function readSize(output: ReadImageOutput): Size | null {
196  const d = output.file?.dimensions
197  const width = d?.originalWidth ?? d?.displayWidth
198  const height = d?.originalHeight ?? d?.displayHeight
199  return width && height ? { width, height } : null
200}
201
202function fileTile(id: string, path: string, size: Size | null): Tile {
203  const name = path.split('/').pop() ?? path
204  return { id, label: name, alt: `[${name}]`, path, size }
205}
206
207// What SendUserFile and SendUserMessage resolve each attachment to.
208type Attachment = { path?: unknown; isImage?: unknown; scaled?: { original_width?: number; original_height?: number } }
209
210type ContentBlock = { type?: unknown; text?: unknown; source?: { media_type?: unknown; data?: unknown } }
211
212const SAVED_IMAGE = /^\[Image: source: (\/.+)\]$/
213
214// An MCP result (an argent screenshot, say) is a list of content blocks, bare or
215// under `content`. Claude Code saves each image block to a file and names it in
216// the text block right after.
217function contentImages(output: unknown, prefix: string): Tile[] {
218  const blocks = Array.isArray(output) ? output : (output as { content?: unknown } | null)?.content
219  if (!Array.isArray(blocks)) return []
220  return (blocks as ContentBlock[]).flatMap((block, i) => {
221    if (block?.type !== 'image') return []
222    const next = blocks[i + 1] as ContentBlock | undefined
223    const path = next?.type === 'text' && typeof next.text === 'string' ? SAVED_IMAGE.exec(next.text)?.[1] : undefined
224    if (path === undefined) return []
225    const { media_type: type, data } = block.source ?? {}
226    const size = type === 'image/png' && typeof data === 'string' ? pngSize(data) : null
227    return [fileTile(`${prefix}-${i}`, path, size)]
228  })
229}
230
231// The images a tool call showed: the file Read loaded, the ones Claude sent
232// with SendUserFile or SendUserMessage, or an MCP tool's image blocks. `path`
233// is Read's, which its result lacks.
234function toolImages(tool: string, output: unknown, path: string | undefined, prefix: string): Tile[] {
235  if (tool === 'Read') {
236    return path !== undefined && isReadImage(output) ? [fileTile(prefix, path, readSize(output))] : []
237  }
238  if (tool.startsWith('mcp__')) return contentImages(output, prefix)
239  if (tool !== 'SendUserFile' && tool !== 'SendUserMessage') return []
240  const attachments = (output as { attachments?: unknown } | null)?.attachments
241  if (!Array.isArray(attachments)) return []
242  return (attachments as Attachment[]).flatMap((attachment, i) => {
243    if (attachment.isImage !== true || typeof attachment.path !== 'string' || !attachment.path.startsWith('/')) return []
244    const { original_width: width, original_height: height } = attachment.scaled ?? {}
245    return [fileTile(`${prefix}-${i}`, attachment.path, width && height ? { width, height } : null)]
246  })
247}
248
249type Elements = ReturnType<EngineInterface['ui']['resolve']>
250
251// One row of tiles, each with its label, which opens the image in Quick Look.
252function tileRow($: EngineInterface, ui: Elements, site: string, tiles: Tile[], cells: Cells[]) {
253  const { Box, Button, Image, Raster, Text } = ui
254  const fit = (i: number) => cells[i] ?? { columns: 4, rows: 1 }
255  const drawn = tiles.flatMap((tile, i) => (tile.path === null ? [] : [{ path: tile.path, ...fit(i) }]))
256  if (isHalfBlock) wantedThumbs.set(site, drawn)
257  else wantedPngs.set(site, drawn.map(tile => tile.path).filter(path => !isPng(path)))
258
259  const placeholder = (text: string, columns: number, rows: number) => (
260    <Box width={columns} height={rows} alignItems="center" justifyContent="center">
261      <Text dimColor wrap="truncate">{text}</Text>
262    </Box>
263  )
264
265  const picture = (tile: Tile & { path: string }, columns: number, rows: number) => {
266    if (isHalfBlock) {
267      const thumb = thumbs.get(thumbKey({ path: tile.path, columns, rows }))
268      if (thumb) return <Raster key={`image-${tile.id}`} columns={columns} rows={rows} cells={thumb} />
269      return placeholder(thumb === null ? tile.alt : '…', columns, rows)
270    }
271    const file = isPng(tile.path) ? tile.path : pngs.get(tile.path)
272    if (file) {
273      return <Image key={`image-${tile.id}`} source={{ file, format: 'png' }} columns={columns} rows={rows} alt={tile.alt} />
274    }
275    return placeholder(file === null ? tile.alt : '…', columns, rows)
276  }
277
278  return (
279    <Box flexDirection="row" columnGap={1}>
280      {tiles.map((tile, i) => {
281        const { columns, rows } = fit(i)
282        return (
283          <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
284            {tile.path === null ? placeholder('no preview', columns, rows) : picture({ ...tile, path: tile.path }, columns, rows)}
285            {tile.path === null ? (
286              <Text dimColor>{tile.label}</Text>
287            ) : (
288              // An Image or Raster can't take a press, so the label under it opens the preview.
289              <Button
290                key={`open-${tile.id}`}
291                plain
292                dimColor
293                label={tile.label}
294                onPress={() => void quickLook($, tile.path!)}
295              />
296            )}
297          </Box>
298        )
299      })}
300    </Box>
301  )
302}
303
304// Transcript tiles: up to 6 picture rows plus border and label, past the row's gutter.
305const TRANSCRIPT_MAX_ROWS = 9
306const MESSAGE_INDENT = 2
307const RESULT_INDENT = 5
308
309function transcriptColumns(e: { viewport?: { columns: number } }, indent: number): number {
310  return (e.viewport?.columns ?? 80) - indent * 2
311}
312
313function isOwnPrompt(e: UiRenderEvent & { component: 'UserMessage' }): boolean {
314  const { origin, from } = e.props
315  return origin.kind === 'composer' || (origin.kind === 'unclassified' && from === undefined)
316}
317
318async function check($: EngineInterface) {
319  if (isChecking) return
320  isChecking = true
321  try {
322    await show($, (await $.prompt.read()).text)
323    await convertWanted($)
324  } finally {
325    isChecking = false
326  }
327}
328
329// Image draws nothing in Zed's terminal, which has no graphics protocol, nor in
330// a background session (`claude attach`, agent view), where Claude Code turns
331// terminal images off unless CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set. A
332// background session can't tell which terminal attaches, so it gets half blocks.
333async function needsHalfBlocks($: EngineInterface): Promise<boolean> {
334  if ((await $.env.get('TERM_PROGRAM')) === 'zed') return true
335  if ((await $.env.get('CLAUDE_CODE_SESSION_KIND')) !== 'bg') return false
336  const forced = await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')
337  return forced === undefined || forced === '' || forced === '0' || forced === 'false'
338}
339
340export const register: Register = on => {
341  on('session.start', async ($, e, next) => {
342    isHalfBlock = await needsHalfBlocks($)
343    $.clock.every(POLL_MS, () => check($))
344    return next(e)
345  })
346
347  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
348    readPaths.set(e.tool_use_id, e.file_path)
349    return next(e)
350  })
351
352  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
353    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
354    const list = await read($, images)
355    if (list.length === 0) {
356      wantedThumbs.delete('above-prompt')
357      wantedPngs.delete('above-prompt')
358      return next(e)
359    }
360
361    const ui = $.ui.resolve(e)
362    const cells = fitRow(list.map(image => image.size), e.props.maxRows, e.props.bodyColumns)
363    const below = await next(e)
364    return (
365      <ui.Box flexDirection="column">
366        {tileRow($, ui, 'above-prompt', list.map(pastedTile), cells)}
367        {below}
368      </ui.Box>
369    )
370  })
371
372  // The same tiles under a sent prompt, whose text keeps its [Image #n] tags.
373  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
374    if (e.surface !== 'terminal' || !isOwnPrompt(e)) return next(e)
375    const list = await imagesOf($, e.requestId, e.props.text)
376    if (list.length === 0) return next(e)
377
378    const ui = $.ui.resolve(e)
379    const cells = fitRow(list.map(image => image.size), TRANSCRIPT_MAX_ROWS, transcriptColumns(e, MESSAGE_INDENT))
380    const message = await next(e)
381    return (
382      <ui.Box flexDirection="column">
383        {message}
384        <ui.Box marginLeft={MESSAGE_INDENT}>{tileRow($, ui, `message-${e.requestId}`, list.map(pastedTile), cells)}</ui.Box>
385      </ui.Box>
386    )
387  })
388
389  // A resumed session ran its Read calls before this module loaded.
390  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
391    const input = e.props.input as { file_path?: unknown } | null
392    if (e.props.tool === 'Read' && typeof input?.file_path === 'string') readPaths.set(e.props.tool_use_id, input.file_path)
393    return next(e)
394  })
395
396  // And under a tool result that showed images: a Read that loaded one
397  // ("Read image (42KB)"), files Claude sent ("› [image] shot.png"), or an MCP
398  // tool's screenshots.
399  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
400    if (e.surface !== 'terminal' || e.props.isErrored) return next(e)
401    const tiles = toolImages(e.props.tool, e.props.output, readPaths.get(e.props.tool_use_id), 'tool')
402    if (tiles.length === 0) return next(e)
403
404    const ui = $.ui.resolve(e)
405    const cells = fitRow(tiles.map(tile => tile.size), TRANSCRIPT_MAX_ROWS, transcriptColumns(e, RESULT_INDENT))
406    const result = await next(e)
407    return (
408      <ui.Box flexDirection="column">
409        {result}
410        <ui.Box marginLeft={RESULT_INDENT}>{tileRow($, ui, `tool-${e.props.tool_use_id}`, tiles, cells)}</ui.Box>
411      </ui.Box>
412    )
413  })
414
415  // Runs of reads fold into one line ("Read 3 files"), with no result rows.
416  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
417    if (e.surface !== 'terminal') return next(e)
418    const tiles = e.props.calls.flatMap((call, i) => {
419      if (call.isErrored) return []
420      const path = (call.input as { file_path?: unknown } | null)?.file_path
421      return toolImages(call.tool, call.output, typeof path === 'string' ? path : undefined, `call-${i}`)
422    })
423    if (tiles.length === 0) return next(e)
424
425    const ui = $.ui.resolve(e)
426    const cells = fitRow(tiles.map(tile => tile.size), TRANSCRIPT_MAX_ROWS, transcriptColumns(e, RESULT_INDENT))
427    const group = await next(e)
428    return (
429      <ui.Box flexDirection="column">
430        {group}
431        <ui.Box marginLeft={RESULT_INDENT}>{tileRow($, ui, `group-${e.requestId}`, tiles, cells)}</ui.Box>
432      </ui.Box>
433    )
434  })
435}
436
hooks/halfblock.ts 91 lines
1// Thumbnails for terminals without a graphics protocol (Zed's): each cell is
2// an upper half block whose foreground is one pixel and background the pixel
3// below it, so a tile `columns` wide and `rows` tall shows columns × 2·rows
4// pixels.
5
6export type Bitmap = { width: number; height: number; rgba: Uint8Array }
7
8const UPPER_HALF = 0x2580
9const DEFAULT_COLOR = 0x01000000
10
11function bytesOf(base64: string): Uint8Array {
12  return Uint8Array.from(atob(base64), char => char.charCodeAt(0))
13}
14
15function channel(pixel: number, mask: number): number {
16  if (mask === 0) return 255
17  let shift = 0
18  while (((mask >>> shift) & 1) === 0) shift++
19  return Math.round((((pixel & mask) >>> shift) * 255) / (mask >>> shift))
20}
21
22/** Top-down RGBA pixels from a 24- or 32-bit uncompressed BMP, as `sips -s format bmp` writes it. */
23export function parseBmp(base64: string): Bitmap | null {
24  const bytes = bytesOf(base64)
25  if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null
26  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
27  const offset = view.getUint32(10, true)
28  const width = view.getInt32(18, true)
29  const signedHeight = view.getInt32(22, true)
30  const bpp = view.getUint16(28, true)
31  const compression = view.getUint32(30, true)
32  const height = Math.abs(signedHeight)
33  if (width <= 0 || height === 0 || (bpp !== 24 && bpp !== 32)) return null
34
35  // BI_RGB (0) is BGR(A); BI_BITFIELDS (3) and BI_ALPHABITFIELDS (6) give masks.
36  let masks = { r: 0xff0000, g: 0xff00, b: 0xff, a: 0 }
37  if (compression === 3 || compression === 6) {
38    masks = {
39      r: view.getUint32(54, true),
40      g: view.getUint32(58, true),
41      b: view.getUint32(62, true),
42      a: view.getUint32(14, true) >= 56 ? view.getUint32(66, true) : 0,
43    }
44  } else if (compression !== 0) {
45    return null
46  }
47
48  const stride = Math.ceil((width * bpp) / 32) * 4
49  if (offset + stride * height > bytes.length) return null
50  const rgba = new Uint8Array(width * height * 4)
51  for (let y = 0; y < height; y++) {
52    // A positive height stores the bottom row first.
53    const row = offset + stride * (signedHeight > 0 ? height - 1 - y : y)
54    for (let x = 0; x < width; x++) {
55      const at = row + (x * bpp) / 8
56      const pixel = bpp === 32 ? view.getUint32(at, true) : bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16)
57      const out = (y * width + x) * 4
58      rgba[out] = channel(pixel, masks.r)
59      rgba[out + 1] = channel(pixel, masks.g)
60      rgba[out + 2] = channel(pixel, masks.b)
61      rgba[out + 3] = channel(pixel, masks.a)
62    }
63  }
64  return { width, height, rgba }
65}
66
67function color(bitmap: Bitmap, x: number, y: number): number {
68  const at = (y * bitmap.width + x) * 4
69  // Transparent pixels show the terminal's own background.
70  if (bitmap.rgba[at + 3] < 128) return DEFAULT_COLOR
71  return (bitmap.rgba[at] << 16) | (bitmap.rgba[at + 1] << 8) | bitmap.rgba[at + 2]
72}
73
74/** A Raster's `cells` for a bitmap already sized to columns × 2·rows pixels. */
75export function halfBlockCells(bitmap: Bitmap, columns: number, rows: number): string | null {
76  if (bitmap.width !== columns || bitmap.height !== rows * 2) return null
77  const words = new DataView(new ArrayBuffer(columns * rows * 12))
78  for (let row = 0; row < rows; row++) {
79    for (let x = 0; x < columns; x++) {
80      const at = (row * columns + x) * 12
81      words.setUint32(at, UPPER_HALF, true)
82      words.setUint32(at + 4, color(bitmap, x, row * 2), true)
83      words.setUint32(at + 8, color(bitmap, x, row * 2 + 1), true)
84    }
85  }
86  const bytes = new Uint8Array(words.buffer)
87  let binary = ''
88  for (const byte of bytes) binary += String.fromCharCode(byte)
89  return btoa(binary)
90}
91
hooks/layout.ts 60 lines
1export type Size = { width: number; height: number }
2export type Cells = { columns: number; rows: number }
3
4const TILE_ROWS = 6
5const MAX_COLUMNS = 32
6const MIN_COLUMNS = 4
7// A terminal cell is about twice as tall as it is wide.
8const CELL_ASPECT = 2
9// Used when the size is unknown (file over $.fs.read's 4 MiB cap, or no file).
10const FALLBACK: Size = { width: 16, height: 10 }
11// Each tile adds a border on every side and a label row under the picture.
12const TILE_CHROME_ROWS = 3
13const TILE_CHROME_COLUMNS = 2
14const GAP = 1
15
16/** The distinct image numbers a draft references, in the order they first appear. */
17export function imageNumbers(draft: string): number[] {
18  const seen = new Set<number>()
19  for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
20  return [...seen]
21}
22
23/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
24export function pngSize(base64: string): Size | null {
25  // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
26  const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
27  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
28  if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
29  const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
30  const width = view.getUint32(16)
31  const height = view.getUint32(20)
32  return width > 0 && height > 0 ? { width, height } : null
33}
34
35/** A picture box `rows` tall that keeps the picture's aspect ratio. */
36export function fitCells(size: Size | null, tileRows = TILE_ROWS): Cells {
37  const { width, height } = size ?? FALLBACK
38  let rows = tileRows
39  let columns = Math.round((rows * CELL_ASPECT * width) / height)
40  if (columns > MAX_COLUMNS) {
41    columns = MAX_COLUMNS
42    rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
43  }
44  return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
45}
46
47/**
48 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
49 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
50 */
51export function fitRow(sizes: readonly (Size | null)[], maxRows: number, bodyColumns: number): Cells[] {
52  const tallest = Math.max(1, Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS))
53  for (let tileRows = tallest; tileRows > 1; tileRows--) {
54    const cells = sizes.map(size => fitCells(size, tileRows))
55    const width = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * (cells.length - 1)
56    if (width <= bodyColumns) return cells
57  }
58  return sizes.map(size => fitCells(size, 1))
59}
60
types/index.d.ts 14 lines
1export type PastedImage = {
2  n: number
3  /** Absolute path of the cached PNG; 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-view': { images: PastedImage[] }
12  }
13}
14