See the images you paste into Claude Code: thumbnails above the prompt, drawn with real pixels in kitty and Ghostty (and iTerm2 with…

A Claude Code mod that shows thumbnails of the images you paste: in the band above the prompt while you write, and in the conversation history once you send, instead of bare [Image #1] tags.
It draws real pixels in terminals that speak the kitty graphics protocol, and falls back to half-block thumbnails in any other truecolour terminal.
| Terminal | Element | What you see |
|---|---|---|
kitty (TERM=xterm-kitty), Ghostty | Image | the picture itself, at full resolution, out of the box |
| iTerm2 3.6+ or nightly (kitty graphics), with the setting below | Image | the picture itself, at full resolution |
| everything else (iTerm2 without the setting, WezTerm, tmux…) | Raster | a half-block thumbnail: ▀ per cell, two truecolour pixels a cell |
iTerm2 draws kitty graphics from 3.6 (and the nightly builds), but Claude Code cannot detect it, so tell it to. Set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 in the environment Claude Code starts from:
export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
or in the env block of Claude Code's settings (~/.claude/settings.json):
{
"env": {
"CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"
}
}
Without it, iTerm2 gets the Raster thumbnails. kitty and Ghostty need nothing.
Claude Code reads the override once, as it starts. Added to settings mid-session, it takes effect after a restart; until then the mod keeps drawing Raster thumbnails.
Raster fallbackFor the Raster path the pasted PNG is scaled with macOS sips to exactly the tile's pixel size (columns × rows × 2), written as an uncompressed BMP beside Claude Code's own copy of the paste, read back, turned into cells, and the temporary file removed. Nothing leaves the machine. Decoded tiles are cached per image and size, so resizing the terminal decodes once per new size.
Off macOS, where there is no sips, the tile reads no preview.
[Image #n] tag resolves to the session's cached paste and draws the same tile as the band. Collapsed rows stay a single line of tags, rows from other origins are left alone, and rows outside the viewport keep the terminal's own rendering, so long transcripts stay cheap. If a thumbnail cannot be drawn, the message text is left untouched.Image tile is sized for the real shape of a terminal cell, so the picture fills its border with no empty bands. The mod API does not report cell pixels, so it assumes a cell 2.3 times as tall as it is wide (iTerm2, kitty and Ghostty at common fonts). If your font leaves thin bands, set IMAGE_VIEW_CELL_ASPECT (1 to 4) to your cell's height over its width: higher removes bands above and below, lower removes them at the sides.CleanShot 2026-10-05 at 00.16.14@2x.png 📸. Names too wide for the tile shorten in the middle and keep their extension; a paste with no recorded file name is labelled [Image #n] 📸.claude --plugin-dir /path/to/claude-plugins/plugins/image-view
Needs Claude Code 2.1.287 or later.
Adapted from jarrodwatts/claude-image-view by Jarrod Watts (MIT): the prompt polling, the paste-folder lookup and the tile layout are his. The Raster fallback is new here. See LICENSE.
hooks/register.tsx 501 lines1import { atom, read, update } from "claude-code"
2import type { EngineInterface, Register } from "claude-code"
3
4import type { PastedImage } from "../types"
5import { cellAspectOf, fitRow, imageLabel, imageNumbers, imageSourcesOf, pngSize } from "./layout"
6import type { Size } from "./layout"
7import {
8 canDrawImages,
9 cellsOf,
10 elapsedSecondsOf,
11 fromBase64,
12 parseBmp,
13} from "./raster"
14
15// Pasting an image raises no prompt.edit (the tag only shows up on the next keystroke),
16// so the draft is polled instead.
17const POLL_MS = 200
18
19const images = atom(
20 { plugin: "image-view", key: "images" } as const,
21 [] as PastedImage[],
22)
23
24let tmpRoot: string | undefined
25let found: { sessionId: string; dir: string } | undefined
26// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
27// while a drawn image's file is still missing, so the next poll looks again.
28let shownKey: string | undefined
29let isChecking = false
30const sizes = new Map<string, Size | null>()
31// Whether the terminal draws `Image` itself; iTerm2 and friends get `Raster` tiles.
32let canDraw = false
33// The cell aspect an Image box is sized with (Raster tiles always use 2).
34let imageCellAspect = cellAspectOf(undefined)
35// Decoded Raster cells by `${path}|${columns}|${rows}`; null when undecodable.
36const rasters = new Map<string, string | null>()
37const decoding = new Set<string>()
38
39// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.png. The project
40// folder is named after a working directory that may since have moved, so find it by the
41// session id instead of rebuilding it.
42async function imagesDir($: EngineInterface): Promise<string | undefined> {
43 const sessionId = await $.session.id()
44 if (found?.sessionId === sessionId) return found.dir
45 if (tmpRoot === undefined) {
46 const fromEnv = await $.env.get("CLAUDE_CODE_TMPDIR")
47 tmpRoot =
48 fromEnv ??
49 `/tmp/claude-${(await $.process.run(["id", "-u"])).stdout.trim()}`
50 }
51 const entries = await $.fs.list(tmpRoot).catch(() => [])
52 for (const entry of entries) {
53 const dir = `${tmpRoot}/${entry.name}/${sessionId}/images`
54 if (entry.kind === "dir" && (await $.fs.exists(dir))) {
55 found = { sessionId, dir }
56 return dir
57 }
58 }
59 return undefined
60}
61
62async function sizeOf(
63 $: EngineInterface,
64 path: string,
65): Promise<Size | null | undefined> {
66 try {
67 const { base64 } = await $.fs.read(path, { as: "bytes" })
68 return pngSize(base64)
69 } catch {
70 // Too big to read: ask sips for the dimensions so the aspect ratio still holds.
71 }
72 try {
73 const { exitCode, stdout } = await $.process.run([
74 "sips",
75 "-g",
76 "pixelWidth",
77 "-g",
78 "pixelHeight",
79 path,
80 ])
81 if (exitCode !== 0) return undefined
82 const width = Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1])
83 const height = Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1])
84 if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined
85 return width > 0 && height > 0 ? { width, height } : null
86 } catch {
87 return undefined
88 }
89}
90
91async function describe(
92 $: EngineInterface,
93 dir: string | undefined,
94 n: number,
95): Promise<PastedImage> {
96 const path = `${dir}/${n}.png`
97 if (dir === undefined || !(await $.fs.exists(path)))
98 return { n, path: null, size: null }
99 if (!sizes.has(path)) {
100 const size = await sizeOf($, path)
101 if (size === null) return { n, path: null, size: null }
102 sizes.set(path, size ?? null)
103 }
104 return { n, path, size: sizes.get(path) ?? null }
105}
106
107async function show($: EngineInterface, draft: string) {
108 const numbers = imageNumbers(draft)
109 const key = numbers.join(",")
110 if (key === shownKey) return
111 // An emptied draft always clears the band on the next poll: the sent images
112 // live on in the transcript, so nothing is kept above the prompt. Clearing
113 // resets the key to the empty draft, so a later paste draws again.
114 const dir = numbers.length > 0 ? await imagesDir($) : undefined
115 const list: PastedImage[] = []
116 for (const n of numbers) list.push(await describe($, dir, n))
117 const sources = imageSourcesOf(draft)
118 list.forEach((image, i) => {
119 if (i < sources.length) image.source = sources[i]
120 })
121 shownKey = list.every((image) => image.path !== null) ? key : undefined
122 forgetRastersOtherThan(list)
123 await update($, images, () => list)
124}
125
126function forgetRastersOtherThan(list: readonly PastedImage[]) {
127 const kept = new Set(list.map((image) => image.path))
128 for (const key of rasters.keys()) {
129 if (!kept.has(key.slice(0, key.indexOf("|")))) rasters.delete(key)
130 }
131}
132
133async function check($: EngineInterface) {
134 if (isChecking) return
135 isChecking = true
136 try {
137 await show($, (await $.prompt.read()).text)
138 } finally {
139 isChecking = false
140 }
141}
142
143function bmpPathOf(path: string, columns: number, rows: number): string {
144 const slash = path.lastIndexOf("/")
145 const dir = slash < 0 ? "." : path.slice(0, slash)
146 const base = slash < 0 ? path : path.slice(slash + 1)
147 const n = base.endsWith(".png") ? base.slice(0, -4) : base
148 return `${dir}/.image-view-${n}-${columns}x${rows}.bmp`
149}
150
151async function decode(
152 $: EngineInterface,
153 path: string,
154 columns: number,
155 rows: number,
156): Promise<void> {
157 const key = `${path}|${columns}|${rows}`
158 if (rasters.has(key) || decoding.has(key)) return
159 decoding.add(key)
160 try {
161 const bmpPath = bmpPathOf(path, columns, rows)
162 const { exitCode } = await $.process.run([
163 "sips",
164 "-z",
165 String(rows * 2),
166 String(columns),
167 "-s",
168 "format",
169 "bmp",
170 path,
171 "--out",
172 bmpPath,
173 ])
174 if (exitCode !== 0) {
175 rasters.set(key, null)
176 return
177 }
178 let parsed: { width: number; height: number; rgba: Uint8Array } | null =
179 null
180 try {
181 const { base64 } = await $.fs.read(bmpPath, { as: "bytes" })
182 parsed = parseBmp(fromBase64(base64))
183 } catch {
184 parsed = null
185 }
186 try {
187 await $.process.run(["rm", "-f", bmpPath])
188 } catch {
189 // The thumbnail is decoded; a leftover temp file is harmless.
190 }
191 // A Raster whose cells do not match its box is refused, so a size sips did not honour is no preview.
192 const fits =
193 parsed !== null &&
194 parsed.width === columns &&
195 Math.ceil(parsed.height / 2) === rows
196 rasters.set(
197 key,
198 fits && parsed ? cellsOf(parsed.rgba, parsed.width, parsed.height) : null,
199 )
200 } catch {
201 rasters.set(key, null)
202 } finally {
203 decoding.delete(key)
204 $.ui.invalidate("ui.render")
205 }
206}
207
208// Claude Code decides whether the terminal draws pictures once, as it starts. A force
209// override written to a settings file after that reaches `$.env` on the next reload but
210// not Claude Code, whose `Image` then shows only its alt; such an override is ignored.
211async function isForceAddedAfterStart(
212 $: EngineInterface,
213 cwd: string,
214): Promise<boolean> {
215 try {
216 const configDir =
217 (await $.env.get("CLAUDE_CONFIG_DIR")) ??
218 `${await $.env.get("HOME")}/.claude`
219 const files = {
220 user: `${configDir}/settings.json`,
221 project: `${cwd}/.claude/settings.json`,
222 local: `${cwd}/.claude/settings.local.json`,
223 } as const
224 const holders: string[] = []
225 for (const source of ["user", "project", "local"] as const) {
226 const { env } = await $.settings.read({ source })
227 if (
228 typeof env === "object" &&
229 env !== null &&
230 "CLAUDE_CODE_FORCE_TERMINAL_IMAGES" in env
231 )
232 holders.push(files[source])
233 }
234 if (holders.length === 0) return false
235 const { exitCode, stdout } = await $.process.run([
236 "sh",
237 "-c",
238 "ps -o etime= -p $PPID",
239 ])
240 const elapsed = exitCode === 0 ? elapsedSecondsOf(stdout) : undefined
241 if (elapsed === undefined) return false
242 const startedAt = (await $.clock.now()) - (elapsed + 1) * 1000
243 for (const path of holders) {
244 const stat = await $.fs.stat(path).catch(() => undefined)
245 if (stat !== undefined && stat.mtimeMs > startedAt) return true
246 }
247 return false
248 } catch {
249 return false
250 }
251}
252
253// A transcript row never pushes the history far: its tiles fit this many rows whole.
254const TRANSCRIPT_MAX_ROWS = 12
255const TRANSCRIPT_FALLBACK_COLUMNS = 80
256
257type Tiles = {
258 Box: any
259 Text: any
260 Image: any
261 Raster: any
262}
263
264function rasterFor(
265 $: EngineInterface,
266 ui: Tiles,
267 path: string,
268 columns: number,
269 rows: number,
270 n: number,
271) {
272 const { Box, Text, Raster } = ui
273 const cached = rasters.get(`${path}|${columns}|${rows}`)
274 if (typeof cached === "string") {
275 return (
276 <Raster
277 key={`image-${n}`}
278 columns={columns}
279 rows={rows}
280 cells={cached}
281 />
282 )
283 }
284 if (cached === null) {
285 return (
286 <Box
287 width={columns}
288 height={rows}
289 alignItems="center"
290 justifyContent="center"
291 >
292 <Text dimColor wrap="truncate">
293 no preview
294 </Text>
295 </Box>
296 )
297 }
298 void decode($, path, columns, rows)
299 return (
300 <Box
301 width={columns}
302 height={rows}
303 alignItems="center"
304 justifyContent="center"
305 >
306 <Text dimColor wrap="truncate">
307 …
308 </Text>
309 </Box>
310 )
311}
312
313// One bordered tile with its picture and file-name label, shared by the band
314// above the prompt and the transcript rows.
315function tileFor(
316 $: EngineInterface,
317 ui: Tiles,
318 image: PastedImage,
319 columns: number,
320 rows: number,
321) {
322 const { Box, Text, Image } = ui
323 return (
324 <Box
325 key={`image-${image.n}`}
326 flexDirection="column"
327 alignItems="center"
328 borderStyle="round"
329 borderColor="inactive"
330 paddingX={1}
331 >
332 {image.path === null ? (
333 <Box
334 width={columns}
335 height={rows}
336 alignItems="center"
337 justifyContent="center"
338 >
339 <Text dimColor wrap="truncate">
340 no preview
341 </Text>
342 </Box>
343 ) : canDraw ? (
344 <Image
345 key={`image-${image.n}`}
346 source={{ file: image.path, format: "png" }}
347 columns={columns}
348 rows={rows}
349 alt=" "
350 />
351 ) : (
352 rasterFor($, ui, image.path, columns, rows, image.n)
353 )}
354 <Text color="text" bold wrap="truncate">
355 {imageLabel(image, columns + 2)}
356 </Text>
357 </Box>
358 )
359}
360
361export const register: Register = (on) => {
362 on("session.start", async ($, e, next) => {
363 const [term, termProgram, forceImages, cellAspect] = await Promise.all([
364 $.env.get("TERM"),
365 $.env.get("TERM_PROGRAM"),
366 $.env.get("CLAUDE_CODE_FORCE_TERMINAL_IMAGES"),
367 $.env.get("IMAGE_VIEW_CELL_ASPECT"),
368 ])
369 const isForceUnseen =
370 forceImages !== undefined && (await isForceAddedAfterStart($, e.cwd))
371 canDraw = canDrawImages({
372 TERM: term ?? undefined,
373 TERM_PROGRAM: termProgram ?? undefined,
374 CLAUDE_CODE_FORCE_TERMINAL_IMAGES: isForceUnseen
375 ? undefined
376 : (forceImages ?? undefined),
377 })
378 imageCellAspect = cellAspectOf(cellAspect ?? undefined)
379 $.clock.every(POLL_MS, () => check($))
380 return next(e)
381 })
382
383 on("turn.complete", async ($, e, next) => {
384 try {
385 shownKey = undefined
386 await update($, images, () => [])
387 } catch {
388 // The band clears on the next poll; never block the turn.
389 }
390 $.ui.invalidate("ui.render")
391 return next(e)
392 })
393
394 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
395 if (e.surface !== "terminal" || e.props.hasSurvey) return next(e)
396 const list = await read($, images)
397 if (list.length === 0) return next(e)
398
399 const ui = $.ui.resolve(e)
400 const { Box } = ui
401 const cells = fitRow(
402 list.map((image) => image.size),
403 e.props.maxRows,
404 e.props.bodyColumns,
405 canDraw ? imageCellAspect : undefined,
406 )
407 const below = await next(e)
408
409 return (
410 <Box flexDirection="column">
411 <Box flexDirection="row" columnGap={1}>
412 {list.map((image, i) => {
413 const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
414 return tileFor($, ui, image, columns, rows)
415 })}
416 </Box>
417 {below}
418 </Box>
419 )
420 })
421
422 on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
423 try {
424 const event = e as unknown as {
425 surface?: unknown
426 viewport?: { columns?: unknown }
427 props?: {
428 text?: unknown
429 origin?: unknown
430 isExpanded?: unknown
431 onScreen?: unknown
432 }
433 }
434 if (event.surface !== "terminal") return next(e)
435 const props = event.props
436 if (typeof props?.text !== "string") return next(e)
437 // Only the composer's own rows carry pasted images; other origins keep
438 // the engine's rendering, as do rows the engine reports as off-screen.
439 // `origin` itself is never modified.
440 const origin = props.origin as { kind?: unknown } | null | undefined
441 if (
442 origin !== undefined &&
443 origin !== null &&
444 typeof origin === "object" &&
445 "kind" in origin &&
446 origin.kind !== "composer"
447 )
448 return next(e)
449 // A collapsed row stays a single line of tags; a row drawn outside the
450 // viewport (`onScreen` null) keeps the engine's rendering, so long
451 // transcripts stay cheap. Where the surface says nothing (`onScreen`
452 // absent), the row draws.
453 if (props.isExpanded === false) return next(e)
454 if (props.onScreen === null) return next(e)
455 const numbers = imageNumbers(props.text)
456 if (numbers.length === 0) return next(e)
457 // The band's polled state is gone by the time older rows render, so each
458 // number resolves to the session's cached paste exactly as the band does.
459 // Decoded tiles come from the same cache, so redrawing is cheap.
460 const dir = await imagesDir($)
461 const list: PastedImage[] = []
462 for (const n of numbers) list.push(await describe($, dir, n))
463 if (!list.some((image) => image.path !== null)) return next(e)
464 const sources = imageSourcesOf(props.text)
465 list.forEach((image, i) => {
466 if (i < sources.length) image.source = sources[i]
467 })
468
469 const ui = $.ui.resolve(e)
470 const { Box } = ui
471 const viewportColumns = event.viewport?.columns
472 const bodyColumns =
473 typeof viewportColumns === "number" && Number.isFinite(viewportColumns)
474 ? viewportColumns
475 : TRANSCRIPT_FALLBACK_COLUMNS
476 const cells = fitRow(
477 list.map((image) => image.size),
478 TRANSCRIPT_MAX_ROWS,
479 bodyColumns,
480 canDraw ? imageCellAspect : undefined,
481 )
482 const message = await next(e)
483
484 return (
485 <Box flexDirection="column">
486 {message}
487 <Box flexDirection="row" columnGap={1}>
488 {list.map((image, i) => {
489 const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
490 return tileFor($, ui, image, columns, rows)
491 })}
492 </Box>
493 </Box>
494 )
495 } catch {
496 // The message text always survives: failures keep the engine's rendering.
497 return next(e)
498 }
499 })
500}
501hooks/layout.ts 184 lines1export type Size = { width: number; height: number }
2export type Cells = { columns: number; rows: number }
3
4// A picture is never taller than this, so the prompt stays usable below it.
5const TILE_ROWS = 20
6// The widest box `Image` takes (`Raster` takes 512); the band width is the real cap.
7const MAX_COLUMNS = 255
8const MIN_COLUMNS = 4
9// A Raster cell holds two pixel rows, so its samples are square at this aspect; sips
10// scales to exactly that grid, so a Raster never letterboxes.
11export const RASTER_CELL_ASPECT = 2
12// An Image keeps the picture's aspect inside its box, so the box must match the real
13// cell (height over width) or the picture letterboxes. The mod API reports no cell
14// pixels: this is a typical one (iTerm2 at JetBrains Mono 14 with 1.1 line spacing is
15// about 2.33, Ghostty and kitty defaults 2.1 to 2.25). IMAGE_VIEW_CELL_ASPECT overrides it.
16export const IMAGE_CELL_ASPECT = 2.3
17// Used when the size is unknown (file over $.fs.read's 4 MiB cap, or no file).
18const FALLBACK: Size = { width: 16, height: 10 }
19// Each tile adds a border on every side, a column of padding each side, and a label row under the picture.
20const TILE_CHROME_ROWS = 3
21const TILE_CHROME_COLUMNS = 4
22const GAP = 1
23
24/** The distinct image numbers a draft references, in the order they first appear. */
25export function imageNumbers(draft: string): number[] {
26 const seen = new Set<number>()
27 for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
28 return [...seen]
29}
30
31/**
32 * The original paths a paste recorded as `[Image: source: /path/to/file.png]`,
33 * in the order they appear. The i-th record belongs to the i-th pasted image.
34 */
35export function imageSourcesOf(text: string): string[] {
36 const found: string[] = []
37 for (const match of text.matchAll(/\[Image:\s*source:\s*([^\]]+)\]/g)) {
38 const path = match[1].trim()
39 if (path !== "") found.push(path)
40 }
41 return found
42}
43
44/** The file name of a recorded source path. */
45export function baseNameOf(path: string): string {
46 const trimmed = path.endsWith("/") ? path.slice(0, -1) : path
47 const slash = trimmed.lastIndexOf("/")
48 return slash < 0 ? trimmed : trimmed.slice(slash + 1)
49}
50
51const WIDE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/
52
53/** Display width in terminal cells: wide characters and emoji (among them 📸) count 2. */
54export function cellWidthOf(text: string): number {
55 let width = 0
56 for (const char of text) {
57 const code = char.codePointAt(0) ?? 0
58 width += code > 0xffff || WIDE.test(char) ? 2 : 1
59 }
60 return width
61}
62
63function takeWidth(text: string, budget: number): string {
64 let width = 0
65 let out = ""
66 for (const char of text) {
67 const next = width + cellWidthOf(char)
68 if (next > budget) break
69 out += char
70 width = next
71 }
72 return out
73}
74
75function takeWidthEnd(text: string, budget: number): string {
76 let width = 0
77 let out = ""
78 for (const char of [...text].reverse()) {
79 const next = width + cellWidthOf(char)
80 if (next > budget) break
81 out = char + out
82 width = next
83 }
84 return out
85}
86
87const ELLIPSIS = "…"
88
89/** Shorten `name` to `budget` cells, keeping the extension and cutting the stem's middle. */
90export function truncateName(name: string, budget: number): string {
91 if (budget <= 0) return ""
92 if (cellWidthOf(name) <= budget) return name
93 const dot = name.lastIndexOf(".")
94 const stem = dot > 0 && dot < name.length - 1 ? name.slice(0, dot) : name
95 const ext = stem === name ? "" : name.slice(dot)
96 const keep = budget - cellWidthOf(ext) - cellWidthOf(ELLIPSIS)
97 if (keep < 2) {
98 const cut = takeWidth(name, budget - cellWidthOf(ELLIPSIS))
99 return cut + (cellWidthOf(cut) < budget ? ELLIPSIS : "")
100 }
101 const endKeep = Math.floor(keep / 2)
102 return takeWidth(stem, keep - endKeep) + ELLIPSIS + takeWidthEnd(stem, endKeep) + ext
103}
104
105const LABEL_SUFFIX = " 📸"
106
107/**
108 * The caption under a tile: the source file's name when the paste recorded one,
109 * else `[Image #n]`. Truncated to `innerWidth` cells (the tile's picture width
110 * plus its padding), extension kept.
111 */
112export function imageLabel(
113 image: { n: number; source?: string | null },
114 innerWidth: number,
115): string {
116 if (innerWidth < cellWidthOf(LABEL_SUFFIX) + 1) return "📸"
117 const name =
118 image.source !== undefined && image.source !== null && image.source !== ""
119 ? baseNameOf(image.source)
120 : `[Image #${image.n}]`
121 return truncateName(name, innerWidth - cellWidthOf(LABEL_SUFFIX)) + LABEL_SUFFIX
122}
123
124/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
125export function pngSize(base64: string): Size | null {
126 // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
127 const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
128 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
129 if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
130 const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
131 const width = view.getUint32(16)
132 const height = view.getUint32(20)
133 return width > 0 && height > 0 ? { width, height } : null
134}
135
136/** The cell aspect IMAGE_VIEW_CELL_ASPECT asks for, when it is a number from 1 to 4. */
137export function cellAspectOf(value: string | undefined, fallback = IMAGE_CELL_ASPECT): number {
138 const aspect = Number(value)
139 return value !== undefined && value.trim() !== "" && aspect >= 1 && aspect <= 4 ? aspect : fallback
140}
141
142/**
143 * A picture box at most `tileRows` tall and `maxColumns` wide that keeps the
144 * picture's aspect ratio: as tall as allowed, unless that would be too wide.
145 */
146export function fitCells(
147 size: Size | null,
148 tileRows = TILE_ROWS,
149 maxColumns = MAX_COLUMNS,
150 cellAspect = RASTER_CELL_ASPECT,
151): Cells {
152 const { width, height } = size ?? FALLBACK
153 const widest = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, maxColumns))
154 let rows = tileRows
155 let columns = Math.round((rows * cellAspect * width) / height)
156 if (columns > widest) {
157 columns = widest
158 rows = Math.max(1, Math.round((widest * height) / (cellAspect * width)))
159 }
160 return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
161}
162
163/**
164 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
165 * the tallest tiles (up to TILE_ROWS) whose chrome fits in `maxRows` and whose
166 * total width fits in `bodyColumns`. A lone picture may take the band's full width.
167 * `cellAspect` is the cell's height over its width as the tile's element draws it.
168 */
169export function fitRow(
170 sizes: readonly (Size | null)[],
171 maxRows: number,
172 bodyColumns: number,
173 cellAspect = RASTER_CELL_ASPECT,
174): Cells[] {
175 const tallest = Math.max(1, Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS))
176 const widest = bodyColumns - TILE_CHROME_COLUMNS
177 for (let tileRows = tallest; tileRows > 1; tileRows--) {
178 const cells = sizes.map(size => fitCells(size, tileRows, widest, cellAspect))
179 const width = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * (cells.length - 1)
180 if (width <= bodyColumns) return cells
181 }
182 return sizes.map(size => fitCells(size, 1, widest, cellAspect))
183}
184hooks/raster.ts 204 lines1/** Base64 over chunks, so one huge string never blows the argument limit. */
2export function toBase64(bytes: Uint8Array): string {
3 let text = ""
4 for (let i = 0; i < bytes.length; i += 0x8000) {
5 text += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
6 }
7 return btoa(text)
8}
9
10export function fromBase64(base64: string): Uint8Array {
11 const text = atob(base64)
12 const bytes = new Uint8Array(text.length)
13 for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i)
14 return bytes
15}
16
17const channelOf = (pixel: number, mask: number): number => {
18 if (mask === 0) return 0
19 let rest = mask >>> 0
20 let shift = 0
21 while ((rest & 1) === 0) {
22 rest >>>= 1
23 shift++
24 }
25 let bits = 0
26 while ((rest & 1) === 1) {
27 rest >>>= 1
28 bits++
29 }
30 if (bits === 0) return 0
31 const value = (pixel >>> shift) & (bits >= 32 ? 0xffffffff : (1 << bits) - 1)
32 return bits >= 8
33 ? value >>> (bits - 8)
34 : Math.round((value * 255) / ((1 << bits) - 1))
35}
36
37/**
38 * RGBA pixels, top-down, from BMP bytes. Handles 24 and 32 bpp with compression
39 * 0 (BI_RGB; 32 bpp reads as BGRX, always opaque) and 3 (BI_BITFIELDS, with the
40 * channel masks at 54/58/62 and the alpha mask at 66 once the header is 56+;
41 * a zero alpha mask means opaque). Null for anything else or truncated data.
42 */
43export function parseBmp(
44 bytes: Uint8Array,
45): { width: number; height: number; rgba: Uint8Array } | null {
46 if (bytes.length < 54) return null
47 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
48 if (view.getUint8(0) !== 0x42 || view.getUint8(1) !== 0x4d) return null
49 const offset = view.getUint32(10, true)
50 const headerSize = view.getUint32(14, true)
51 if (headerSize < 40) return null
52 const width = view.getInt32(18, true)
53 const heightRaw = view.getInt32(22, true)
54 if (width <= 0 || heightRaw === 0) return null
55 if (view.getUint16(26, true) !== 1) return null
56 const bpp = view.getUint16(28, true)
57 if (bpp !== 24 && bpp !== 32) return null
58 const compression = view.getUint32(30, true)
59 if (compression !== 0 && compression !== 3) return null
60
61 let red = 0
62 let green = 0
63 let blue = 0
64 let alpha = 0
65 if (compression === 3) {
66 if (bytes.length < 66) return null
67 red = view.getUint32(54, true)
68 green = view.getUint32(58, true)
69 blue = view.getUint32(62, true)
70 if (headerSize >= 56) {
71 if (bytes.length < 70) return null
72 alpha = view.getUint32(66, true)
73 }
74 if (offset < (headerSize >= 56 ? 70 : 66)) return null
75 } else if (offset < 14 + headerSize) {
76 return null
77 }
78
79 const height = Math.abs(heightRaw)
80 const topDown = heightRaw < 0
81 const stride = Math.floor((bpp * width + 31) / 32) * 4
82 if (offset + stride * height > bytes.length) return null
83
84 let rgba: Uint8Array
85 try {
86 rgba = new Uint8Array(width * height * 4)
87 } catch {
88 return null
89 }
90 const step = bpp / 8
91 for (let y = 0; y < height; y++) {
92 const row = offset + (topDown ? y : height - 1 - y) * stride
93 for (let x = 0; x < width; x++) {
94 const at = row + x * step
95 let r: number
96 let g: number
97 let b: number
98 let a = 255
99 if (compression === 3) {
100 const pixel =
101 step === 4
102 ? view.getUint32(at, true)
103 : view.getUint8(at) |
104 (view.getUint8(at + 1) << 8) |
105 (view.getUint8(at + 2) << 16)
106 r = channelOf(pixel, red)
107 g = channelOf(pixel, green)
108 b = channelOf(pixel, blue)
109 a = alpha === 0 ? 255 : channelOf(pixel, alpha)
110 } else {
111 b = view.getUint8(at)
112 g = view.getUint8(at + 1)
113 r = view.getUint8(at + 2)
114 }
115 const out = (y * width + x) * 4
116 rgba[out] = r
117 rgba[out + 1] = g
118 rgba[out + 2] = b
119 rgba[out + 3] = a
120 }
121 }
122 return { width, height, rgba }
123}
124
125const TERMINAL_DEFAULT = 0x01000000
126const UPPER_HALF = 0x2580
127const LOWER_HALF = 0x2584
128
129/**
130 * A Raster `cells` string for `columns = width`, `rows = ceil(height / 2)`: each
131 * cell folds two pixels into one half-block glyph, transparent past the last row
132 * of an odd height. Alpha under 128 reads as transparent.
133 */
134export function cellsOf(
135 rgba: Uint8Array,
136 width: number,
137 height: number,
138): string {
139 const rows = Math.ceil(height / 2)
140 const words = new Uint32Array(width * rows * 3)
141 const opaque = (x: number, y: number): number | null => {
142 if (x < 0 || x >= width || y < 0 || y >= height) return null
143 const at = (y * width + x) * 4
144 const [r = 0, g = 0, b = 0, a = 0] = rgba.subarray(at, at + 4)
145 return a >= 128 ? (r << 16) | (g << 8) | b : null
146 }
147 let i = 0
148 for (let row = 0; row < rows; row++) {
149 for (let x = 0; x < width; x++) {
150 const top = opaque(x, row * 2)
151 const bottom = opaque(x, row * 2 + 1)
152 let code = 0x20
153 let fg = TERMINAL_DEFAULT
154 let bg = TERMINAL_DEFAULT
155 if (top !== null && bottom !== null) {
156 code = UPPER_HALF
157 fg = top
158 bg = bottom
159 } else if (top !== null) {
160 code = UPPER_HALF
161 fg = top
162 } else if (bottom !== null) {
163 code = LOWER_HALF
164 fg = bottom
165 }
166 words[i++] = code
167 words[i++] = fg >>> 0
168 words[i++] = bg >>> 0
169 }
170 }
171 return toBase64(new Uint8Array(words.buffer))
172}
173
174const isForced = (value: string | undefined): boolean =>
175 value !== undefined && !["", "0", "false"].includes(value.trim().toLowerCase())
176
177/**
178 * True only where the terminal draws the `Image` element itself (kitty graphics). The
179 * mod API reports no such capability, so this mirrors Claude Code: its force override
180 * (iTerm2 nightly and other kitty-graphics terminals), then kitty and Ghostty.
181 */
182export function canDrawImages(env: {
183 TERM?: string
184 TERM_PROGRAM?: string
185 CLAUDE_CODE_FORCE_TERMINAL_IMAGES?: string
186}): boolean {
187 return (
188 isForced(env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) ||
189 env.TERM === "xterm-kitty" ||
190 env.TERM_PROGRAM === "ghostty"
191 )
192}
193
194/** Seconds a process has run, from `ps -o etime=` (`[[dd-]hh:]mm:ss`); undefined when unreadable. */
195export function elapsedSecondsOf(etime: string): number | undefined {
196 const match = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(etime.trim())
197 if (match === null) return undefined
198 const [, days = "0", hours = "0", minutes, seconds] = match
199 return (
200 ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 +
201 Number(seconds)
202 )
203}
204types/index.d.ts 16 lines1export 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 /** Original file path the paste recorded (`[Image: source: …]`), if any; labels use its name. */
8 source?: string | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'image-view': { images: PastedImage[] }
14 }
15}
16