SLOPSHOPPER

image-view

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

newbandrowsprocesstimer
★ 1v0.3.0MITupdated 2026-10-06kud/claude-plugins/plugins/image-view
A shopper browsing a rack in a slop shop
README

image-view

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.

How it draws

TerminalElementWhat you see
kitty (TERM=xterm-kitty), GhosttyImagethe picture itself, at full resolution, out of the box
iTerm2 3.6+ or nightly (kitty graphics), with the setting belowImagethe picture itself, at full resolution
everything else (iTerm2 without the setting, WezTerm, tmux…)Rastera half-block thumbnail: ▀ per cell, two truecolour pixels a cell

iTerm2: turn on real pixels

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.

The Raster fallback

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

Behaviour

  • Thumbnails appear as soon as you paste (the draft is polled every 200 ms, since a paste raises no edit event).
  • Sent messages keep their thumbnails in the conversation history: each [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.
  • Tiles are sized from the band: a lone picture can use the full width, up to 20 rows tall, with its aspect ratio kept; the row of tiles always fits the band, shrinking before it would scroll. History tiles are smaller, capped so one message never pushes the transcript far.
  • An 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.
  • Each tile has a rounded grey border, a column of padding, and its file name in bold, centred directly under the picture, e.g. 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] 📸.
  • Sending the prompt clears the band; the sent images show in the history.

Try it for one session

claude --plugin-dir /path/to/claude-plugins/plugins/image-view

Needs Claude Code 2.1.287 or later.

Credit

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.

Source 4 files
hooks/register.tsx 501 lines
1import { 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}
501
hooks/layout.ts 184 lines
1export 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}
184
hooks/raster.ts 204 lines
1/** 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}
204
types/index.d.ts 16 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  /** 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