SLOPSHOPPER

cc-preview

Preview images and markdown files from the conversation and the prompt: hover cards, and a side pane to browse them

newpanebandrowscommandtoast
★ 9v0.3.0MITupdated 2026-10-09andrew54068/claude-plugins/cc-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-preview
│ ┃ Preview ✕ › fix the failing auth test and add an audit log call │ ┃ Conversation artifacts 0 [ ↻ Refresh ] │ ┃ No images or markdown files in this ⏺ Read(src/auth.ts) │ ┃ conversation yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /preview │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Preview
Conversation artifacts 0 [ ↻ Refresh ] No images or markdown files in this conversation yet.
README

cc-preview

A Claude Code mod that previews the images and markdown files of a conversation: hover cards in the transcript, thumbnails above the prompt, and a side pane to read or browse them.

  • Pasted images in the prompt: while the draft holds [Image #n] tags, a row of thumbnails sits above the prompt. Press #n ⤢ under one to open it in the pane.
  • Sent prompts: each pasted image gets an [ img #n ] button. Hover it to see the picture; click it, or the card's ⤢ Zoom, to open it in the pane.
  • Images and markdown in the conversation: a reply, a prompt or a tool row that names an image (.png .jpg .jpeg .gif .webp) or a markdown file (.md .markdown .mdx) that exists gets a button per file (▣ chart.png, ≡ plan.md). Hover for the picture or the file's first lines; click to open it in the pane. A tool that returns a picture (an MCP screenshot) gets one too.
  • Browse everything: /preview lists every artifact in the conversation, images first, newest first. In the pane: 1–9 pick, p / n page, a back to the list, c copies the path, g switches graphics and blocks, i / o zoom and h j k l move (blocks, docked), r refreshes, Esc closes. /preview <path> opens one file.

Hover and click need a mouse, which Claude Code has in its fullscreen layout ("tui": "fullscreen"). The pane docks beside the transcript from 110 columns; narrower (a split terminal), it opens above the prompt instead.

Install

From the andrew54068 marketplace:

claude plugin marketplace add andrew54068/claude-plugins
claude plugin install cc-preview@andrew54068 --scope user

Or for one session from a clone: claude --plugin-dir /path/to/claude-plugins/cc-preview.

Needs Claude Code 2.1.289 or later. Markdown draws in any terminal. Pictures draw in kitty graphics where Claude Code knows the terminal shows them (Ghostty, kitty, WezTerm, found from TERM_PROGRAM / TERM), and in block characters everywhere else (below), so nothing needs setting up to see them.

Herdr passes kitty graphics but reports TERM=xterm-256color, so Claude Code can't tell and pictures fall back to blocks. For graphics, tell it in ~/.zshrc or ~/.bashrc (new panes only):

if [ -n "$HERDR_ENV" ]; then
  export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
fi

The pane's mode button names what was found: ▣ Graphics · … or ▦ Blocks · ….

Pictures over ssh, mosh and multiplexers

Every picture is sent to the terminal as bytes, never as a file name, so it shows when Claude Code runs on another machine.

  • ssh: kitty graphics pass through.
  • Graphics or characters, found for you. Pictures use kitty graphics only where they are known to reach the screen; everywhere else they are drawn in quadrant block characters (▘▝▀▖▌▞▛, four pixels a cell, 24-bit color), which every terminal and mosh carry. The terminal can't be asked directly (its reply would land in Claude Code's input), so the path to it is read: the processes above Claude Code, and through herdr, tmux, zellij or screen, the client whose terminal had input last. Graphics need all of: Claude Code sending them (CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1, or a kitty, Ghostty or WezTerm terminal), no mosh on the path, and a local terminal app that draws them. Looked at again every 5 s, so detaching and attaching over another connection switches within seconds; the pane's mode button names what was found (▦ Blocks · mosh, ▣ Graphics · ssh, ▣ Graphics · ghostty).
  • mosh keeps its own copy of the screen and sends only text and colors, so graphics never reach the client. Characters are a hard ceiling: a 100-column pane holds 200 pixels across. A short pane fills its width and scrolls (↑↓). Docked beside the transcript, the picture zooms: i / o step from the fit up to 100% (each pixel of the picture a pixel of the blocks across), and h j k l move the window. For the whole picture at full resolution at once, use ssh.
  • The pictures option (/config) pins the choice: auto (default), graphics or blocks. g in the pane switches it until the path changes.

Image takes at most 2 MiB of PNG: a JPEG, GIF or WebP is converted once, a bigger PNG is shrunk until it fits, and hover cards get a copy at most 512 px long. Block pictures are read from a BMP the converter writes at twice the cell size each way. Converters are tried in order, 10 s each: sips (macOS), ffmpeg, magick, convert; only sips has been tried. Copies go to <Claude Code temp dir>/cc-preview/<session id>/, made private (700) after ownership and permission checks; nothing is written when a check fails, and nothing is deleted.

What it reads and runs

  • Files the conversation names, only with the extensions above: stat to show a button, read for a hover card or the pane (markdown up to 4 MiB, drawn in pieces of 9000 characters, at most 40).
  • The conversation through $.session.messages, when the list opens, after each turn while it is open, and once for a resumed conversation's pasted images; a sent prompt's images as it is stored.
  • The draft every 200 ms, and the paste cache <temp dir>/<project>/<session>/images/.
  • ~/.claude/settings.json (or the one under CLAUDE_CONFIG_DIR) once, for the language.
  • External commands: id -u, sh, ps and stat (every 5 s, for the path), mkdir, chmod, find, head, base64, mv, and the converters, arguments passed as arguments, never spliced into shell code.
  • No network, no model calls.

Options

OptionValuesDefault
picturesauto, graphics, blocksauto
languageauto, en, zh-TW (auto follows Claude Code's language, then LC_ALL / LANG)auto

Checks

claude plugin validate .
claude plugin test .
tsc -p .   # TypeScript 5.5 or later

Credits

The paste-cache lookup, converters, private-folder check, hover cards, thumbnail layout and language picking are adapted from GGGODLIN/cc-mod-image-view and jarrodwatts/claude-image-view, MIT. See NOTICE.

Source 10 files
hooks/register.tsx 1185 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, PluginOptions, Register, RenderElement, RenderViewport } from 'claude-code'
3
4import type { Artifact, DraftImage, PictureMode, ZoomView } from '../types'
5import { parseBmp, quadrants } from './blocks'
6import type { Pixels } from './blocks'
7import { clientTerminals, drawsGraphics, parseInputTimes, parseProcesses, pathLabel, pathOf } from './connection'
8import type { Path, TerminalEnv } from './connection'
9import { imagesIn, promptImages, scan, shortTool, toolPaths } from './conversation'
10import type { Block, Message, Picture, PromptImages, Words } from './conversation'
11import { pickLocale, stringsFor } from './i18n'
12import type { Strings } from './i18n'
13import {
14  buttonOffsets,
15  cellWidth,
16  clamp,
17  fitBox,
18  fitCells,
19  fitLabel,
20  fitRow,
21  imageNumbers,
22  keepCenter,
23  pngSize,
24  zoomLevels,
25  zoomed,
26} from './layout'
27import type { Cells, Size, Zoomed } from './layout'
28import { markdownChunks } from './markdown'
29import type { Chunked } from './markdown'
30import {
31  BMP_CONVERTERS,
32  CONVERTERS,
33  CONVERTIBLE,
34  EXTENSIONS,
35  MAX_PNG_BYTES,
36  PNG,
37  PRIVATE_DIRS,
38  RESIZERS,
39  digest,
40  memo,
41  shrunkEdge,
42} from './media'
43import type { Master } from './media'
44import { basename, cleanPath, folderOf, kindOf, mentionsArtifact, pathsIn, resolvePath } from './paths'
45
46const PANE = 'cc-preview'
47// Pasting an image raises no prompt.edit (the tag only shows up on the next keystroke),
48// so the draft is polled instead.
49const POLL_MS = 200
50// Files checked per scan, so a conversation full of paths can't stall it
51const MAX_STATS = 400
52// A file's existence is trusted this long, since transcript rows draw again and again
53const STAT_MS = 3_000
54const MAX_MARKDOWN_BYTES = 4 * 1024 * 1024
55// Tools whose input names the file they touched; their row gets a button for it
56const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
57// A hover card's picture is sent at most this long on its longest side: sharp, and light to send
58const THUMB_EDGE = 512
59// A hover card's picture box; block pictures get a bigger one, two pixels a cell being coarse
60const CARD_BOX: Record<PictureMode, { rows: number; columns: number }> = { graphics: { rows: 8, columns: 40 }, blocks: { rows: 12, columns: 48 } }
61// A markdown file's hover card shows its first lines
62const CARD_LINES = 14
63const CARD_CHARS = 1_200
64// A button's widest label; a longer file name loses its middle
65const LABEL_CELLS = 28
66
67const artifacts = atom({ plugin: 'cc-preview', key: 'artifacts' } as const, [] as Artifact[])
68const selected = atom({ plugin: 'cc-preview', key: 'selected' } as const, null as Artifact | null)
69const draft = atom({ plugin: 'cc-preview', key: 'draft' } as const, [] as DraftImage[])
70const mode = atom({ plugin: 'cc-preview', key: 'mode' } as const, 'blocks' as PictureMode)
71const link = atom({ plugin: 'cc-preview', key: 'link' } as const, '')
72const zoom = atom({ plugin: 'cc-preview', key: 'zoom' } as const, { id: '', level: 0, x: 0, y: 0 } as ZoomView)
73
74type Terminal = Elements['terminal']
75type Place = { cwd: string; root: string; home: string | undefined }
76
77// English until session.start has read the language settings
78let strings: Strings = stringsFor('en')
79const words = (): Words => ({ pasted: strings.pasted, toolResult: strings.toolResult })
80
81let place: Place | undefined
82
83async function placeOf($: EngineInterface): Promise<Place> {
84  place ??= { cwd: await $.session.cwd(), root: await $.session.root(), home: await $.env.get('HOME') }
85  return place
86}
87
88async function settingsLanguage($: EngineInterface): Promise<unknown> {
89  const home = await $.env.get('HOME')
90  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
91  try {
92    return (JSON.parse(await $.fs.read(`${config}/settings.json`)) as { language?: unknown }).language
93  } catch {
94    return undefined
95  }
96}
97
98// Kitty graphics or characters: looked at again every few seconds, since the person may detach
99// from a multiplexer and attach again over another connection
100const DETECT_MS = 5_000
101// This process (the shell's parent) and every process, in one call
102const PROCESSES = 'echo "$PPID"; ps -axo pid=,ppid=,tty=,comm='
103// When each terminal last had input: BSD stat, else GNU
104const INPUT_TIMES = 'stat -f "%a %N" "$@" 2>/dev/null || stat -c "%X %n" "$@" 2>/dev/null; true'
105
106// A background run that has not finished in this long is taken for lost, and the next one goes
107const STUCK_MS = 10_000
108
109let terminalEnv: TerminalEnv = { force: undefined, termProgram: undefined, term: undefined }
110// What was last found, so a switch made in the pane stands until the path changes
111let detected: string | undefined
112let detectingSince = 0
113
114async function pathNow($: EngineInterface): Promise<Path | null> {
115  const run = await $.process.run(['sh', '-c', PROCESSES], { timeoutMs: 5_000 }).catch(() => null)
116  if (run === null || run.exitCode !== 0 || run.isStdoutTruncated) return null
117  const [start = '', ...rest] = run.stdout.split('\n')
118  const processes = parseProcesses(rest.join('\n'))
119  const terminals = clientTerminals(processes)
120  const times =
121    terminals.length === 0
122      ? null
123      : await $.process.run(['sh', '-c', INPUT_TIMES, 'sh', ...terminals.map(tty => `/dev/${tty}`)], { timeoutMs: 5_000 }).catch(() => null)
124  return pathOf(processes, Number(start.trim()), parseInputTimes(times?.stdout ?? ''))
125}
126
127async function detect($: EngineInterface, option: unknown) {
128  if (Date.now() - detectingSince < STUCK_MS) return
129  detectingSince = Date.now()
130  try {
131    const path = await pathNow($)
132    if (path === null) return
133    const isPinned = option === 'graphics' || option === 'blocks'
134    const look: PictureMode = isPinned ? option : drawsGraphics(path, terminalEnv) ? 'graphics' : 'blocks'
135    const label = pathLabel(path)
136    if (`${label}|${look}` === detected) return
137    detected = `${label}|${look}`
138    note($, `path ${path.via}${path.terminal === null ? '' : ` (${path.terminal})`} -> ${look}${isPinned ? ' (pinned)' : ''}`)
139    await update($, link, () => label)
140    await update($, mode, () => look)
141  } finally {
142    detectingSince = 0
143  }
144}
145
146// ── The trail ────────────────────────────────────────────────────────────────
147// What the mod did lately, kept in this session's scratch folder as debug.log (the last 200
148// lines), so a picture that doesn't show can be traced without a --debug session
149
150const TRAIL_LINES = 200
151const trail: string[] = []
152let trailPath: Promise<string | null> | undefined
153let isTrailPending = false
154
155function note($: EngineInterface, line: string) {
156  trail.push(`${new Date().toISOString().slice(11, 23)} ${line}`)
157  if (trail.length > TRAIL_LINES) trail.splice(0, trail.length - TRAIL_LINES)
158  if (isTrailPending) return
159  isTrailPending = true
160  // Written a moment later, outside whatever dispatch noted it, many lines at once
161  $.clock.after(300, () => {
162    isTrailPending = false
163    trailPath ??= scratch($, 'debug.log')
164    void trailPath.then(path => (path === null ? undefined : $.fs.write(path, `${trail.join('\n')}\n`))).catch(() => undefined)
165  })
166}
167
168// ── Artifacts ────────────────────────────────────────────────────────────────
169
170const files = new Map<string, { at: number; artifact: Artifact | null }>()
171
172/** The image or markdown file a path as written leads to, if it exists. */
173async function fileArtifact($: EngineInterface, raw: string): Promise<Artifact | null> {
174  const here = await placeOf($)
175  const path = resolvePath(raw, here.cwd, here.home)
176  const kind = path === null ? null : kindOf(path)
177  if (path === null || kind === null) return null
178  const known = files.get(path)
179  if (known !== undefined && Date.now() - known.at < STAT_MS) return known.artifact
180  const stat = await $.fs.stat(path, { resolve: true }).catch(() => null)
181  const real = stat?.kind === 'file' ? (stat.realPath ?? path) : null
182  const artifact: Artifact | null =
183    real === null
184      ? null
185      : { id: `file:${real}`, kind, label: basename(path), detail: folderOf(path, here.root, here.home), source: { type: 'file', path: real } }
186  files.set(path, { at: Date.now(), artifact })
187  return artifact
188}
189
190/** The files among `raws` that exist, each once. */
191async function existing($: EngineInterface, raws: readonly string[]): Promise<Artifact[]> {
192  const list: Artifact[] = []
193  for (const raw of raws.slice(0, 12)) {
194    const artifact = await fileArtifact($, raw)
195    if (artifact !== null && !list.some(known => known.id === artifact.id)) list.push(artifact)
196  }
197  return list
198}
199
200// Image bytes the conversation holds, by digest, the last used kept. Module memory: a reload
201// (or the cap) drops them, and the next look reads them from the conversation again.
202const MAX_BLOBS = 100
203const blobs = new Map<string, { mediaType: string; base64: string }>()
204
205async function blobArtifact(picture: Picture): Promise<Artifact> {
206  const id = await digest(picture.base64)
207  blobs.delete(id)
208  blobs.set(id, { mediaType: picture.mediaType, base64: picture.base64 })
209  for (const old of blobs.keys()) {
210    if (blobs.size <= MAX_BLOBS) break
211    blobs.delete(old)
212  }
213  return { id: `blob:${id}`, kind: 'image', label: picture.label, detail: picture.detail, source: { type: 'blob', id } }
214}
215
216// A sent prompt's pictures, by its words. A row says only its words, so two prompts with the
217// same words and different pictures are null: no preview beats the wrong one.
218type PromptIndex = Map<string, Artifact[] | null>
219let prompts: PromptIndex = new Map()
220let isIndexed = false
221
222async function indexPrompt(index: PromptIndex, prompt: PromptImages) {
223  const list = await Promise.all(prompt.pictures.map(blobArtifact))
224  const key = prompt.text.trim()
225  const known = index.get(key)
226  const ids = (some: Artifact[]) => some.map(artifact => artifact.id).join(',')
227  if (known === undefined) index.set(key, list)
228  else if (known !== null && ids(known) !== ids(list)) index.set(key, null)
229}
230
231let reading: Promise<Artifact[]> | undefined
232
233/**
234 * Reads the whole conversation: rebuilds the prompt index and the bytes, and answers every
235 * artifact, newest first. Writes no state, so a draw may wait on it.
236 */
237function readConversation($: EngineInterface): Promise<Artifact[]> {
238  reading ??= (async () => {
239    const messages = (await $.session.messages({ as: 'api' })) as readonly Message[]
240    const found = scan(messages, words())
241    const index: PromptIndex = new Map()
242    for (const prompt of found.prompts) await indexPrompt(index, prompt)
243    prompts = index
244    isIndexed = true
245    const list: Artifact[] = []
246    const seen = new Set<string>()
247    let stats = 0
248    // Newest first: a file named again lately is near the top
249    for (const item of [...found.items].reverse()) {
250      let artifact: Artifact | null = null
251      if (item.type === 'picture') artifact = await blobArtifact(item.picture)
252      else if (stats++ < MAX_STATS) artifact = await fileArtifact($, item.raw)
253      if (artifact !== null && !seen.has(artifact.id)) {
254        seen.add(artifact.id)
255        list.push(artifact)
256      }
257    }
258    // Pictures, then documents: the order the list shows and Prev / Next walk
259    return [...list.filter(artifact => artifact.kind === 'image'), ...list.filter(artifact => artifact.kind === 'markdown')]
260  })().finally(() => {
261    reading = undefined
262  })
263  return reading
264}
265
266async function refresh($: EngineInterface) {
267  const list = await readConversation($)
268  await update($, artifacts, () => list)
269}
270
271type Call = { tool_use_id?: string; tool: string; input: unknown; output?: unknown }
272
273// A call's pictures by its id: its row draws again and again, and a digest of each would cost
274const toolPictures = new Map<string, Artifact[]>()
275
276/** The pictures of tool results that carry them (a screenshot), and the files a file tool touched. */
277async function toolArtifacts($: EngineInterface, call: Call, kinds: 'all' | 'images'): Promise<Artifact[]> {
278  if (FILE_TOOLS.has(call.tool)) {
279    const list = await existing($, toolPaths(call.tool, call.input))
280    return kinds === 'all' ? list : list.filter(artifact => artifact.kind === 'image')
281  }
282  if (call.tool === 'Bash') return []
283  const known = call.tool_use_id === undefined ? undefined : toolPictures.get(call.tool_use_id)
284  if (known !== undefined) return known
285  const images = imagesIn(call.output)
286  const name = shortTool(call.tool)
287  const list = await Promise.all(
288    images.map((image, i) => blobArtifact({ ...image, label: images.length > 1 ? `${name} ${i + 1}` : name, detail: strings.toolResult })),
289  )
290  if (call.tool_use_id !== undefined) toolPictures.set(call.tool_use_id, list)
291  return list
292}
293
294const draftArtifact = (n: number, path: string): Artifact => ({
295  id: `draft:${path}`,
296  kind: 'image',
297  label: `Image #${n}`,
298  detail: strings.inPrompt,
299  source: { type: 'file', path },
300})
301
302// ── Pictures ─────────────────────────────────────────────────────────────────
303// $ is only followed into functions of this file, so everything that calls it lives here.
304// Every picture reaches the terminal as bytes (a PNG, or block cells), never as a file name:
305// over ssh the terminal runs on another machine and cannot read this one's files.
306
307let tmpRoot: string | undefined
308
309/** Claude Code's own temp folder: `CLAUDE_CODE_TMPDIR`, else `/tmp/claude-<uid>`. */
310async function tempRoot($: EngineInterface): Promise<string> {
311  if (tmpRoot === undefined) {
312    const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
313    tmpRoot = fromEnv ?? `/tmp/claude-${(await $.process.run(['id', '-u'])).stdout.trim()}`
314  }
315  return tmpRoot
316}
317
318/** A path in this session's private scratch folder, made (and checked) first; null when it can't be. */
319async function scratch($: EngineInterface, name: string): Promise<string | null> {
320  const top = await tempRoot($)
321  const base = `${top}/cc-preview`
322  const dir = `${base}/${await $.session.id()}`
323  const run = await $.process.run(['sh', '-c', PRIVATE_DIRS, 'sh', top, base, dir], { timeoutMs: 5_000 }).catch(() => null)
324  return run?.exitCode === 0 ? `${dir}/${name}` : null
325}
326
327// One job per output, so two draws asking for the same picture never write it at once
328const jobs = new Map<string, Promise<string | null>>()
329
330function once(out: string, make: () => Promise<string | null>): Promise<string | null> {
331  const running = jobs.get(out)
332  if (running !== undefined) return running
333  const job = make().finally(() => jobs.delete(out))
334  jobs.set(out, job)
335  return job
336}
337
338// Written under a temporary name and renamed into place, so a reader never sees half a file
339async function publish($: EngineInterface, temp: string, out: string): Promise<string | null> {
340  const run = await $.process.run(['mv', '-f', temp, out], { timeoutMs: 5_000 }).catch(() => null)
341  return run?.exitCode === 0 ? out : null
342}
343
344/** Runs each command until one writes the temp file, then moves it to `out`; a file already there is kept. */
345function produce($: EngineInterface, out: string, commands: (temp: string) => string[][]): Promise<string | null> {
346  return once(out, async () => {
347    if (await $.fs.exists(out)) return out
348    // Same extension: a converter picks its output format by it
349    const temp = out.replace(/(\.[A-Za-z0-9]+)$/, '.part$1')
350    for (const argv of commands(temp)) {
351      const ok = await $.process.run(argv, { timeoutMs: 10_000 }).then(run => run.exitCode === 0, () => false)
352      if (ok && (await $.fs.exists(temp))) return publish($, temp, out)
353    }
354    return null
355  })
356}
357
358/** Writes base64 bytes to `out`, for a picture the conversation holds. */
359function writeBytes($: EngineInterface, out: string, base64: string): Promise<string | null> {
360  return once(out, async () => {
361    if (await $.fs.exists(out)) return out
362    const temp = `${out}.part`
363    const run = await $.process
364      .run(['sh', '-c', 'umask 077; base64 -d > "$1"', 'sh', temp], { stdin: base64, timeoutMs: 10_000 })
365      .catch(() => null)
366    return run?.exitCode === 0 && (await $.fs.exists(temp)) ? publish($, temp, out) : null
367  })
368}
369
370/** A PNG's size from its first bytes, read without loading a file that may be past $.fs.read's 4 MiB. */
371async function headerSize($: EngineInterface, path: string): Promise<Size | null> {
372  const run = await $.process.run(['sh', '-c', 'head -c 32 "$1" | base64', 'sh', path], { timeoutMs: 5_000 }).catch(() => null)
373  return run?.exitCode === 0 ? pngSize(run.stdout.replace(/\s/g, '')) : null
374}
375
376async function sizeOnDisk($: EngineInterface, path: string): Promise<number> {
377  return (await $.fs.stat(path).catch(() => null))?.size ?? -1
378}
379
380async function readBase64($: EngineInterface, path: string): Promise<string | null> {
381  return (await $.fs.read(path, { as: 'bytes' }).catch(() => null))?.base64 ?? null
382}
383
384/** An image file as a PNG on disk: itself, or a converted copy. */
385async function masterFromFile($: EngineInterface, path: string, bytes: number, key: string): Promise<Master | null> {
386  if (PNG.test(path)) {
387    const size = await headerSize($, path)
388    return size === null ? null : { path, size, bytes }
389  }
390  if (!CONVERTIBLE.test(path)) return null
391  const out = await scratch($, `${await digest(key)}.png`)
392  const png = out === null ? null : await produce($, out, temp => CONVERTERS(path, temp))
393  const size = png === null ? null : await headerSize($, png)
394  const pngBytes = png === null ? -1 : await sizeOnDisk($, png)
395  return png === null || size === null || pngBytes < 0 ? null : { path: png, size, bytes: pngBytes }
396}
397
398const masters = memo<Master>(64)
399
400/** The PNG every drawing of an artifact's picture is made from. */
401async function masterOf($: EngineInterface, artifact: Artifact): Promise<Master | null> {
402  if (artifact.source.type === 'file') {
403    const path = artifact.source.path
404    const stat = await $.fs.stat(path).catch(() => null)
405    if (stat?.kind !== 'file') return null
406    const key = `${path}|${stat.mtimeMs}|${stat.size}`
407    return masters(key, () => masterFromFile($, path, stat.size, key))
408  }
409  const id = artifact.source.id
410  return masters(`blob:${id}`, async () => {
411    // A reload emptied the bytes; the conversation still has them
412    if (!blobs.has(id)) await readConversation($)
413    const blob = blobs.get(id)
414    const extension = blob === undefined ? undefined : EXTENSIONS[blob.mediaType]
415    const out = blob === undefined || extension === undefined ? null : await scratch($, `${id}.${extension}`)
416    const file = blob === undefined || out === null ? null : await writeBytes($, out, blob.base64)
417    const bytes = file === null ? -1 : await sizeOnDisk($, file)
418    return file === null || bytes < 0 ? null : masterFromFile($, file, bytes, `blob:${id}`)
419  })
420}
421
422const pngs = memo<string>(24)
423
424/** PNG bytes of a picture, at most `edge` on its longest side (null: as is) and 2 MiB, which `Image` takes. */
425function pngOf($: EngineInterface, master: Master, edge: number | null): Promise<string | null> {
426  return pngs(`${master.path}|${master.bytes}|${edge ?? 'full'}`, async () => {
427    const longest = Math.max(master.size.width, master.size.height)
428    const isTooBig = master.bytes > MAX_PNG_BYTES
429    if ((edge === null || longest <= edge) && !isTooBig) return readBase64($, master.path)
430    const name = await digest(`${master.path}|${master.bytes}`)
431    let target = Math.min(edge ?? longest, longest, isTooBig ? shrunkEdge(longest, master.bytes) : longest)
432    for (let tries = 0; tries < 4 && target >= 16; tries++) {
433      const edgeNow = target
434      const out = await scratch($, `${name}-${edgeNow}.png`)
435      const shrunk = out === null ? null : await produce($, out, temp => RESIZERS(master.path, temp, edgeNow, master.size))
436      const bytes = shrunk === null ? -1 : await sizeOnDisk($, shrunk)
437      if (shrunk === null || bytes < 0) return null
438      if (bytes <= MAX_PNG_BYTES) return readBase64($, shrunk)
439      target = shrunkEdge(target, bytes)
440    }
441    return null
442  })
443}
444
445const bitmaps = memo<Pixels>(3)
446
447/** A picture's pixels at `width` × `height`, read from a BMP the converter writes. */
448function pixelsOf($: EngineInterface, master: Master, width: number, height: number): Promise<Pixels | null> {
449  return bitmaps(`${master.path}|${master.bytes}|${width}x${height}`, async () => {
450    const out = await scratch($, `${await digest(`${master.path}|${master.bytes}`)}-${width}x${height}.bmp`)
451    const bmp = out === null ? null : await produce($, out, temp => BMP_CONVERTERS(master.path, temp, width, height))
452    const base64 = bmp === null ? null : await readBase64($, bmp)
453    return base64 === null ? null : parseBmp(base64)
454  })
455}
456
457type Crop = Cells & { x: number; y: number }
458
459const rasters = memo<string>(48)
460
461/**
462 * A picture `full` cells big as quadrant blocks, four pixels a cell, for a terminal mosh stands
463 * before: the cells of `crop`, the whole picture unless a zoomed pane shows a part.
464 */
465function blocksOf($: EngineInterface, master: Master, full: Cells, crop: Crop = { x: 0, y: 0, ...full }): Promise<string | null> {
466  const area = `${full.columns}x${full.rows}|${crop.x},${crop.y},${crop.columns}x${crop.rows}`
467  return rasters(`${master.path}|${master.bytes}|${area}`, async () => {
468    const pixels = await pixelsOf($, master, full.columns * 2, full.rows * 2)
469    if (pixels === null) return null
470    const window: Pixels = {
471      width: Math.max(1, pixels.width - crop.x * 2),
472      height: Math.max(1, pixels.height - crop.y * 2),
473      rgb: (x, y) => pixels.rgb(x + crop.x * 2, y + crop.y * 2),
474    }
475    return quadrants(window, crop.columns, crop.rows)
476  })
477}
478
479/** The element that draws a picture in `cells`: an Image, or under mosh a Raster of blocks. */
480async function drawPicture(
481  $: EngineInterface,
482  ui: Terminal,
483  master: Master,
484  cells: Cells,
485  look: { mode: PictureMode; edge: number | null; key: string; alt: string },
486): Promise<RenderElement | null> {
487  const { Image, Raster } = ui
488  if (look.mode === 'blocks') {
489    const raster = await blocksOf($, master, cells)
490    return raster === null ? null : <Raster key={look.key} columns={cells.columns} rows={cells.rows} cells={raster} />
491  }
492  const png = await pngOf($, master, look.edge)
493  return png === null ? null : <Image key={look.key} source={{ png }} columns={cells.columns} rows={cells.rows} alt={look.alt} />
494}
495
496// ── Documents ────────────────────────────────────────────────────────────────
497
498const documents = memo<Chunked>(4)
499
500/** A markdown file as Markdown pieces, or why it can't be shown. */
501async function documentOf($: EngineInterface, path: string): Promise<Chunked | string> {
502  const stat = await $.fs.stat(path).catch(() => null)
503  if (stat?.kind !== 'file') return strings.documentGone
504  if (stat.size > MAX_MARKDOWN_BYTES) return strings.documentTooBig
505  const chunked = await documents(`${path}|${stat.mtimeMs}|${stat.size}`, async () => {
506    const text = await $.fs.read(path).catch(() => null)
507    return text === null ? null : markdownChunks(text)
508  })
509  return chunked ?? strings.documentUnreadable
510}
511
512/** A markdown file's first lines, for its hover card. */
513async function documentHead($: EngineInterface, path: string): Promise<string | null> {
514  const document = await documentOf($, path)
515  const first = typeof document === 'string' ? undefined : document.chunks[0]
516  if (first === undefined) return null
517  const lines = first.split('\n')
518  const head = lines.slice(0, CARD_LINES).join('\n').slice(0, CARD_CHARS)
519  const isCut = lines.length > CARD_LINES || head.length < first.length
520  return markdownChunks(isCut ? `${head}\n\n…` : head, CARD_CHARS + 200, 1).chunks[0] ?? null
521}
522
523// ── The pane ─────────────────────────────────────────────────────────────────
524
525/** The dock's width: under half the terminal, room for a picture or a page of text. */
526const paneColumns = (columns: number | undefined) => Math.max(50, Math.min(120, Math.floor((columns ?? 120) * 0.45)))
527/** The inline pane's height, where it opens above the prompt (a narrow terminal, a split). */
528const paneRows = (rows: number | undefined) => Math.max(12, Math.floor((rows ?? 40) * 0.6))
529
530/**
531 * Shows `artifact` in the pane (null: the list). The pane is opened before anything is awaited,
532 * while the press or command that asked for it is the one running: an asked pane is seated at
533 * any width, an unasked one only from 144 columns, which a split terminal never has.
534 */
535function open($: EngineInterface, artifact: Artifact | null, viewport: { columns?: number; rows?: number } | undefined) {
536  const opening = $.ui.open({
537    id: PANE,
538    title: strings.paneTitle,
539    focus: true,
540    closeOnEscape: true,
541    columns: paneColumns(viewport?.columns),
542    rows: paneRows(viewport?.rows),
543  })
544  void update($, selected, () => artifact)
545  return opening.then(opened => {
546    note($, `open ${artifact?.label ?? 'list'} -> ${opened.isPlaced ? 'placed' : `waiting: ${opened.reason}`}`)
547    if (!opened.isPlaced) $.ui.toast(strings.notPlaced(opened.reason))
548    // The band above the prompt has less room beside an inline pane: drawn again for it
549    $.clock.after(150, () => $.ui.invalidate('ui.render'))
550    return opened
551  })
552}
553
554// The docked pane's last drawn zoom, which its buttons step from: a press handler may not read
555// what a draw computed any other way
556type Geometry = { id: string; levels: number[]; fit: Cells; box: Cells; level: number; x: number; y: number; shape: Zoomed }
557let geometry: Geometry | undefined
558
559/** One zoom level in (1) or out (-1), what was in the window's middle kept there. */
560function zoomBy($: EngineInterface, delta: number) {
561  const now = geometry
562  if (now === undefined) return
563  void update($, zoom, view => {
564    // Presses ahead of the redraw step on from each other
565    const at = view.id === now.id ? view : now
566    const from = zoomed(now.fit, now.box, now.levels[at.level] ?? 1)
567    const level = clamp(at.level + delta, 0, now.levels.length - 1)
568    return { id: now.id, level, ...keepCenter(at, from, zoomed(now.fit, now.box, now.levels[level] ?? 1)) }
569  })
570}
571
572/** Moves a zoomed picture's window half its size, `dx` across and `dy` down. */
573function pan($: EngineInterface, dx: number, dy: number) {
574  const now = geometry
575  if (now === undefined) return
576  void update($, zoom, view => {
577    const at = view.id === now.id ? view : now
578    const { full, window } = zoomed(now.fit, now.box, now.levels[at.level] ?? 1)
579    return {
580      id: now.id,
581      level: at.level,
582      x: clamp(at.x + dx * Math.max(1, Math.floor(window.columns / 2)), 0, full.columns - window.columns),
583      y: clamp(at.y + dy * Math.max(1, Math.floor(window.rows / 2)), 0, full.rows - window.rows),
584    }
585  })
586}
587
588async function step($: EngineInterface, list: readonly Artifact[], delta: number) {
589  await update($, selected, current => {
590    const at = list.findIndex(artifact => artifact.id === current?.id)
591    return at < 0 || list.length === 0 ? current : (list[(at + delta + list.length) % list.length] ?? current)
592  })
593}
594
595const glyph = (artifact: Artifact) => (artifact.kind === 'image' ? '▣' : '≡')
596
597/** A button's label: `img #n` for a paste, as cc-image-view had it; the file's name otherwise. */
598function buttonLabel(artifact: Artifact): string {
599  const paste = /^Image #(\d+)$/.exec(artifact.label)
600  if (paste !== null) return strings.sentButton(Number(paste[1]))
601  return `${glyph(artifact)} ${fitLabel(artifact.label, LABEL_CELLS)}`
602}
603
604// ── Transcript rows ──────────────────────────────────────────────────────────
605
606type Site = { requestId: string; viewport?: RenderViewport }
607type Card = { node: RenderElement; width: number }
608
609/** What hovering a button shows: the picture, or a markdown file's first lines; and a button to open it in the pane. */
610async function cardOf($: EngineInterface, ui: Terminal, site: Site, artifact: Artifact, at: number, room: number): Promise<Card | null> {
611  const { Box, Button, Markdown } = ui
612  const zoom = (
613    <Button
614      key={`cc-preview:zoom:${at}`}
615      label={artifact.kind === 'image' ? strings.zoom : strings.open}
616      dimColor
617      onPress={() => void open($, artifact, site.viewport)}
618    />
619  )
620  if (artifact.kind === 'markdown') {
621    const head = artifact.source.type === 'file' ? await documentHead($, artifact.source.path) : null
622    if (head === null) return null
623    const width = Math.min(72, room)
624    return {
625      width,
626      node: (
627        <Box width={width} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
628          <Markdown text={head} />
629          {zoom}
630        </Box>
631      ),
632    }
633  }
634  const master = await masterOf($, artifact)
635  if (master === null) return null
636  const look = await read($, mode)
637  const box = CARD_BOX[look]
638  const cells = fitCells(master.size, box.rows, Math.min(box.columns, room - 2))
639  const picture = await drawPicture($, ui, master, cells, { mode: look, edge: THUMB_EDGE, key: `cc-preview:card:${at}`, alt: artifact.label })
640  if (picture === null) return null
641  return {
642    width: Math.max(cells.columns, cellWidth(strings.zoom) + 4) + 2,
643    node: (
644      <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
645        {picture}
646        {zoom}
647      </Box>
648    ),
649  }
650}
651
652/**
653 * A row of buttons under a transcript row, one per artifact, each lighting a card on hover
654 * (cc-image-view's way): the card sits in the flow under the buttons, shifted under its own
655 * button, so the rows below move down while it shows and the buttons never do.
656 */
657async function previewRow($: EngineInterface, ui: Terminal, site: Site, list: readonly Artifact[]): Promise<RenderElement> {
658  const { Box, Button } = ui
659  // Indented as a reply's text is, a few cells spare at the right
660  const room = Math.max(24, (site.viewport?.columns ?? 100) - 8)
661  const labels: string[] = []
662  let used = 0
663  for (const artifact of list) {
664    const label = buttonLabel(artifact)
665    const width = cellWidth(label) + 5
666    const isLast = labels.length === list.length - 1
667    // Room kept for the `+n more` button unless this is the last
668    if (used + width > room - (isLast ? 0 : 14)) break
669    labels.push(label)
670    used += width
671  }
672  const shown = list.slice(0, labels.length)
673  const more = list.length - shown.length
674  const offsets = buttonOffsets(labels)
675  // A hover group spans every site, so each row names its own
676  const scopeOf = (at: number) => `cc-preview:${site.requestId.slice(-40)}:${at}`
677  const cards = await Promise.all(shown.map((artifact, at) => cardOf($, ui, site, artifact, at, room)))
678  return (
679    <Box flexDirection="column" marginLeft={2}>
680      <Box flexDirection="row" columnGap={1}>
681        {shown.map((artifact, at) => (
682          <Box hover={{ scope: scopeOf(at) }}>
683            <Button key={`cc-preview:open:${at}`} label={labels[at] ?? ''} dimColor onPress={() => void open($, artifact, site.viewport)} />
684          </Box>
685        ))}
686        {more > 0 && (
687          <Button
688            key="cc-preview:more"
689            label={strings.more(more)}
690            dimColor
691            onPress={() => {
692              void open($, null, site.viewport)
693              void refresh($)
694            }}
695          />
696        )}
697      </Box>
698      {cards.map((card, at) =>
699        card === null ? null : (
700          <Box
701            display="none"
702            hover={{ scope: scopeOf(at), display: 'flex' }}
703            marginLeft={Math.max(0, Math.min(offsets[at] ?? 0, room - card.width))}
704            flexDirection="column"
705            alignItems="flex-start"
706          >
707            {card.node}
708          </Box>
709        ),
710      )}
711    </Box>
712  )
713}
714
715/** The engine's row with the preview row under it. */
716async function withRow($: EngineInterface, ui: Terminal, site: Site, row: RenderElement, list: readonly Artifact[]): Promise<RenderElement> {
717  const { Box } = ui
718  return (
719    <Box flexDirection="column">
720      {row}
721      {await previewRow($, ui, site, list)}
722    </Box>
723  )
724}
725
726// ── The draft's pasted images ────────────────────────────────────────────────
727
728let found: { sessionId: string; dir: string } | undefined
729
730// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.<ext>. The project
731// folder is named after a working directory that may since have moved, so find it by the
732// session id instead of rebuilding it.
733// Adapted from cc-image-view (https://github.com/GGGODLIN/cc-mod-image-view), MIT; see NOTICE.
734async function imagesDir($: EngineInterface): Promise<string | undefined> {
735  const sessionId = await $.session.id()
736  if (found?.sessionId === sessionId) return found.dir
737  const base = await tempRoot($)
738  for (const entry of await $.fs.list(base).catch(() => [])) {
739    const dir = `${base}/${entry.name}/${sessionId}/images`
740    if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
741      found = { sessionId, dir }
742      return dir
743    }
744  }
745  return undefined
746}
747
748/** The cached paste for image `n`, whatever its extension (a JPEG paste is `<n>.jpg`). */
749async function cachedFile($: EngineInterface, dir: string | undefined, n: number): Promise<string | null> {
750  if (dir === undefined) return null
751  const entries = await $.fs.list(dir).catch(() => [])
752  const hit = entries.find(entry => entry.kind === 'file' && new RegExp(`^${n}\\.[A-Za-z0-9]+$`).test(entry.name))
753  return hit === undefined ? null : `${dir}/${hit.name}`
754}
755
756// The image numbers last found whole, so an unchanged draft doesn't look again; undefined
757// while one's file is still missing, so the next poll does.
758let draftKey: string | undefined
759let written = '[]'
760let checkingSince = 0
761
762async function checkDraft($: EngineInterface) {
763  if (Date.now() - checkingSince < STUCK_MS) return
764  checkingSince = Date.now()
765  try {
766    const numbers = imageNumbers((await $.prompt.read()).text)
767    const key = numbers.join(',')
768    if (key === draftKey) return
769    const dir = numbers.length > 0 ? await imagesDir($) : undefined
770    const list: DraftImage[] = []
771    for (const n of numbers) list.push({ n, path: await cachedFile($, dir, n) })
772    draftKey = list.every(image => image.path !== null) ? key : undefined
773    const json = JSON.stringify(list)
774    if (json === written) return
775    written = json
776    note($, `draft ${list.map(image => `#${image.n}=${image.path === null ? 'missing' : 'found'}`).join(' ') || 'empty'}`)
777    await update($, draft, () => list)
778  } finally {
779    checkingSince = 0
780  }
781}
782
783// ── Starting ─────────────────────────────────────────────────────────────────
784
785let isStarted = false
786
787/** The language, what Claude Code was told about the terminal, and a first look at the path. */
788async function configure($: EngineInterface, options: PluginOptions) {
789  // An empty LC_ALL means unset to the C library, so it must not hide LANG
790  const lcAll = await $.env.get('LC_ALL')
791  const envLang = lcAll !== undefined && lcAll !== '' ? lcAll : await $.env.get('LANG')
792  strings = stringsFor(pickLocale({ option: options.language, claudeLanguage: await settingsLanguage($), envLang }))
793  terminalEnv = {
794    force: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
795    termProgram: await $.env.get('TERM_PROGRAM'),
796    term: await $.env.get('TERM'),
797  }
798  note($, `env force=${terminalEnv.force ?? '-'} TERM_PROGRAM=${terminalEnv.termProgram ?? '-'} TERM=${terminalEnv.term ?? '-'}`)
799  await detect($, options.pictures)
800  $.ui.invalidate('ui.render')
801}
802
803/**
804 * Starts the background work once per load: the draft poll that feeds the band above the prompt,
805 * and the look at the path to the screen. From session.start, and from whichever hook runs first
806 * should session.start not get there (it is not seen to run after every mid-session load), so a
807 * lost start can't leave the band empty and the pictures in a mode the screen can't show.
808 */
809function startBackground($: EngineInterface, options: PluginOptions, isAwaited = false): Promise<void> | undefined {
810  if (isStarted) return undefined
811  isStarted = true
812  note($, `start from ${isAwaited ? 'session.start' : 'a hook'}`)
813  $.clock.every(POLL_MS, () => checkDraft($))
814  $.clock.every(DETECT_MS, () => detect($, options.pictures))
815  // session.start is awaited before the first prompt, so the first draw is in the right mode;
816  // from any other hook, outside its dispatch, since a draw may not write state
817  if (isAwaited) return configure($, options)
818  $.clock.after(0, () => void configure($, options))
819  return undefined
820}
821
822// ── Hooks ────────────────────────────────────────────────────────────────────
823
824export const register: Register = (on, options) => {
825  on('session.start', async ($, e, next) => {
826    await startBackground($, options, true)
827    // A refused command costs /preview alone, never the rest
828    await $.command
829      .register({
830        name: 'preview',
831        description: 'Browse the images and markdown files of this conversation in a side pane',
832        argumentHint: '[path]',
833        immediate: true,
834      })
835      .catch(() => undefined)
836    return next(e)
837  })
838
839  on('prompt.submit', async ($, e, next) => {
840    startBackground($, options)
841    return next(e)
842  })
843
844  on('command.run', { command: 'preview' }, async ($, e) => {
845    startBackground($, options)
846    const arg = e.args.trim().replace(/^(["'`])(.*)\1$/, '$2')
847    let artifact: Artifact | null = null
848    if (arg !== '') {
849      artifact = await fileArtifact($, cleanPath(arg) ?? arg)
850      if (artifact === null) return { text: strings.notFound(arg) }
851    }
852    const opening = open($, artifact, { columns: e.presentation.columns })
853    await refresh($)
854    const opened = await opening
855    return opened.isPlaced ? {} : { text: strings.notPlaced(opened.reason) }
856  })
857
858  // A sent prompt's pictures are indexed as its row is stored, before the row draws
859  on('session.append', { door: 'prompt' }, async ($, e, next) => {
860    startBackground($, options)
861    const prompt = promptImages(e.message.content as readonly Block[], words())
862    if (prompt !== null) await indexPrompt(prompts, prompt)
863    const stored = await next(e)
864    if (prompt !== null) $.ui.invalidate('ui.render')
865    return stored
866  })
867
868  on('turn.complete', async ($, e, next) => {
869    startBackground($, options)
870    const result = await next(e)
871    if (e.agentId !== undefined) return result
872    // The working directory may have moved, and files named before they were written exist now:
873    // look again, and draw the rows again so their buttons catch up
874    place = undefined
875    files.clear()
876    $.ui.invalidate('ui.render')
877    if ((await $.ui.panes()).some(pane => pane.id === PANE)) $.clock.after(0, () => void refresh($))
878    return result
879  })
880
881  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
882    startBackground($, options)
883    if (e.surface !== 'terminal' || !mentionsArtifact(e.props.text)) return next(e)
884    const list = await existing($, pathsIn(e.props.text))
885    if (list.length === 0) return next(e)
886    return withRow($, $.ui.resolve(e), e, await next(e), list)
887  })
888
889  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
890    startBackground($, options)
891    if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer') return next(e)
892    const text = e.props.text
893    const hasTags = imageNumbers(text).length > 0
894    if (!hasTags && !mentionsArtifact(text)) return next(e)
895    // A resumed conversation was never appended here: read it once
896    if (hasTags && !isIndexed) await readConversation($)
897    const pictures = hasTags ? (prompts.get(text.trim()) ?? []) : []
898    const list = [...pictures, ...(await existing($, pathsIn(text)))]
899    if (list.length === 0) return next(e)
900    return withRow($, $.ui.resolve(e), e, await next(e), list)
901  })
902
903  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
904    startBackground($, options)
905    if (e.surface !== 'terminal' || e.props.isRunning || e.props.isErrored || e.props.isInterrupted) return next(e)
906    const list = await toolArtifacts($, e.props, 'all')
907    if (list.length === 0) return next(e)
908    return withRow($, $.ui.resolve(e), e, await next(e), list)
909  })
910
911  // A folded run of reads: only its pictures get buttons, so a run of doc reads stays one line
912  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
913    startBackground($, options)
914    if (e.surface !== 'terminal' || e.props.isExpanded) return next(e)
915    const list: Artifact[] = []
916    for (const call of e.props.calls) {
917      if (call.isRunning || call.isErrored || call.isInterrupted) continue
918      for (const artifact of await toolArtifacts($, call, 'images')) {
919        if (!list.some(known => known.id === artifact.id)) list.push(artifact)
920      }
921    }
922    if (list.length === 0) return next(e)
923    return withRow($, $.ui.resolve(e), e, await next(e), list)
924  })
925
926  // Thumbnails of the images the prompt being typed refers to
927  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
928    startBackground($, options)
929    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
930    const list = await read($, draft)
931    if (list.length === 0) return next(e)
932    const look = await read($, mode)
933    const ui = $.ui.resolve(e)
934    const { Box, Button, Text } = ui
935    // Squeezed (a pane opened inline above the prompt takes the room), the tiles drop their
936    // borders, then their pictures, so what is left shows whole rather than scroll out of sight
937    const room = e.props.maxRows
938    const isBordered = room >= 5
939    const hasPictures = room >= 2
940    const tiles = list.map(image => ({ image, artifact: image.path === null ? null : draftArtifact(image.n, image.path) }))
941    const sources = await Promise.all(tiles.map(tile => (hasPictures && tile.artifact !== null ? masterOf($, tile.artifact) : null)))
942    const cells = fitRow(
943      sources.map(master => master?.size ?? null),
944      room,
945      e.props.bodyColumns,
946      isBordered ? { columns: 2, rows: 3 } : { columns: 0, rows: 1 },
947    )
948    const pictures = await Promise.all(
949      tiles.map(({ image }, i) => {
950        const master = sources[i] ?? null
951        const box = cells[i]
952        return master === null || box === undefined
953          ? null
954          : drawPicture($, ui, master, box, { mode: look, edge: THUMB_EDGE, key: `draft-${image.n}`, alt: `[Image #${image.n}]` })
955      }),
956    )
957    note(
958      $,
959      `band rows=${room} columns=${e.props.bodyColumns} ${look} ${tiles
960        .map(({ image }, i) => `#${image.n}:${pictures[i] === null ? 'none' : `${cells[i]?.columns}x${cells[i]?.rows}`}`)
961        .join(' ')}`,
962    )
963    const below = await next(e)
964    const label = (image: DraftImage, artifact: Artifact | null) =>
965      artifact === null ? (
966        <Text dimColor>#{image.n}</Text>
967      ) : (
968        <Button key={`cc-preview:draft:${image.n}`} label={`#${image.n} ⤢`} plain dimColor onPress={() => void open($, artifact, e.viewport)} />
969      )
970    return (
971      <Box flexDirection="column">
972        <Box flexDirection="row" columnGap={1}>
973          {tiles.map(({ image, artifact }, i) => {
974            const picture = pictures[i] ?? null
975            const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
976            if (!hasPictures) return label(image, artifact)
977            return (
978              <Box flexDirection="column" alignItems="center" {...(isBordered ? { borderStyle: 'round', borderDimColor: true } : {})}>
979                {picture ?? (
980                  <Box width={columns} height={rows} alignItems="center" justifyContent="center">
981                    <Text dimColor wrap="truncate">{strings.noPreview}</Text>
982                  </Box>
983                )}
984                {label(image, picture === null ? null : artifact)}
985              </Box>
986            )
987          })}
988        </Box>
989        {below}
990      </Box>
991    )
992  }).catch(($, e, next) => {
993    note($, `band failed: ${next.error.kind} ${next.error.message ?? ''}`)
994    return next(e)
995  })
996
997  // The band's room changes with an inline pane's: drawn again once it closes
998  on('ui.close', { id: PANE }, async ($, e, next) => {
999    const closed = await next(e)
1000    $.clock.after(150, () => $.ui.invalidate('ui.render'))
1001    return closed
1002  })
1003
1004  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1005    startBackground($, options)
1006    if (e.surface !== 'terminal') {
1007      const { Text } = $.ui.resolve(e)
1008      return <Text dimColor>{strings.terminalOnly}</Text>
1009    }
1010    const ui = $.ui.resolve(e)
1011    const { Box, Button, Markdown, Text } = ui
1012    const list = await read($, artifacts)
1013    const current = await read($, selected)
1014    const look = await read($, mode)
1015    const via = await read($, link)
1016    const columns = e.props.bodyColumns
1017    const rows = e.props.scroll.bodyRows
1018    note($, `pane ${e.props.placement} ${columns}x${rows} ${look}: ${current === null ? 'list' : current.label}`)
1019
1020    if (current === null) {
1021      const entry = (artifact: Artifact) => {
1022        const at = list.indexOf(artifact)
1023        const hotkey = at < 9 ? { hotkey: String(at + 1) } : {}
1024        return (
1025          <Box flexDirection="row" columnGap={1}>
1026            <Button key={`cc-preview:pick:${at}`} {...hotkey} plain label={`${glyph(artifact)} ${artifact.label}`} onPress={() => void update($, selected, () => artifact)} />
1027            <Text dimColor wrap="truncate-middle">{artifact.detail}</Text>
1028          </Box>
1029        )
1030      }
1031      const images = list.filter(artifact => artifact.kind === 'image')
1032      const markdown = list.filter(artifact => artifact.kind === 'markdown')
1033      return (
1034        <Box flexDirection="column">
1035          <Box flexDirection="row" columnGap={1}>
1036            <Text bold>{strings.listTitle}</Text>
1037            <Text dimColor>{String(list.length)}</Text>
1038            <Button key="cc-preview:refresh" label={strings.refresh} hotkey="r" dimColor onPress={() => void refresh($)} />
1039          </Box>
1040          {list.length === 0 && <Text dimColor>{strings.empty}</Text>}
1041          {images.length > 0 && <Text bold>{strings.images}</Text>}
1042          {images.map(entry)}
1043          {markdown.length > 0 && <Text bold>{strings.markdown}</Text>}
1044          {markdown.map(entry)}
1045        </Box>
1046      )
1047    }
1048
1049    const at = list.findIndex(artifact => artifact.id === current.id)
1050    const path = current.source.type === 'file' ? current.source.path : null
1051    const header = (
1052      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1053        {at >= 0 && list.length > 1 && <Button key="cc-preview:prev" label={strings.prev} hotkey="p" dimColor onPress={() => void step($, list, -1)} />}
1054        {at >= 0 && <Text dimColor>{`${at + 1}/${list.length}`}</Text>}
1055        {at >= 0 && list.length > 1 && <Button key="cc-preview:next" label={strings.next} hotkey="n" dimColor onPress={() => void step($, list, 1)} />}
1056        <Button
1057          key="cc-preview:all"
1058          label={strings.all}
1059          hotkey="a"
1060          dimColor
1061          onPress={() => {
1062            void update($, selected, () => null)
1063            void refresh($)
1064          }}
1065        />
1066        {path !== null && (
1067          <Button key="cc-preview:copy" label={strings.copy} hotkey="c" dimColor onPress={press => void $.ui.copy({ text: path, surface: press.surface })} />
1068        )}
1069        {current.kind === 'image' && (
1070          <Button
1071            key="cc-preview:mode"
1072            label={`${look === 'blocks' ? strings.blocks : strings.graphics}${via === '' ? '' : ` · ${via}`}`}
1073            hotkey="g"
1074            dimColor
1075            onPress={() => void update($, mode, now => (now === 'blocks' ? 'graphics' : 'blocks'))}
1076          />
1077        )}
1078      </Box>
1079    )
1080    const title = (
1081      <Box flexDirection="row" columnGap={1}>
1082        <Text bold wrap="truncate">{`${glyph(current)} ${current.label}`}</Text>
1083        <Text dimColor wrap="truncate-middle">{current.detail}</Text>
1084      </Box>
1085    )
1086
1087    if (current.kind === 'image') {
1088      const master = await masterOf($, current)
1089      // Docked beside the transcript, a block picture zooms in toward one pixel of the picture to
1090      // each of the blocks', and moves about: all its detail, a part at a time
1091      if (master !== null && look === 'blocks' && e.props.placement === 'dock') {
1092        const { Raster } = ui
1093        // Header, zoom bar and title, one to spare should the buttons wrap
1094        const box = { columns, rows: Math.max(1, rows - 4) }
1095        const fit = fitBox(master.size, box.columns, box.rows)
1096        const levels = zoomLevels(master.size, fit)
1097        const saved = await read($, zoom)
1098        const view = saved.id === current.id ? saved : { id: current.id, level: 0, x: 0, y: 0 }
1099        const level = clamp(view.level, 0, levels.length - 1)
1100        const shape = zoomed(fit, box, levels[level] ?? 1)
1101        const x = clamp(view.x, 0, shape.full.columns - shape.window.columns)
1102        const y = clamp(view.y, 0, shape.full.rows - shape.window.rows)
1103        geometry = { id: current.id, levels, fit, box, level, x, y, shape }
1104        const raster = await blocksOf($, master, shape.full, { x, y, ...shape.window })
1105        note($, `pane zoom ${levels[level]?.toFixed(2)}x window ${shape.window.columns}x${shape.window.rows} -> ${raster === null ? 'none' : 'drawn'}`)
1106        const canMove = shape.full.columns > shape.window.columns || shape.full.rows > shape.window.rows
1107        return (
1108          <Box flexDirection="column">
1109            {header}
1110            {levels.length > 1 && (
1111              <Box flexDirection="row" columnGap={1}>
1112                <Button key="cc-preview:zoom-out" label="−" hotkey="o" dimColor onPress={() => zoomBy($, -1)} />
1113                <Text dimColor>{`${Math.round((200 * shape.full.columns) / master.size.width)}%`}</Text>
1114                <Button key="cc-preview:zoom-in" label="+" hotkey="i" dimColor onPress={() => zoomBy($, 1)} />
1115                {canMove && (
1116                  <Box flexDirection="row" columnGap={1}>
1117                    <Button key="cc-preview:left" label="◀" hotkey="h" dimColor onPress={() => pan($, -1, 0)} />
1118                    <Button key="cc-preview:up" label="▲" hotkey="k" dimColor onPress={() => pan($, 0, -1)} />
1119                    <Button key="cc-preview:down" label="▼" hotkey="j" dimColor onPress={() => pan($, 0, 1)} />
1120                    <Button key="cc-preview:right" label="▶" hotkey="l" dimColor onPress={() => pan($, 1, 0)} />
1121                  </Box>
1122                )}
1123                <Text dimColor>{canMove ? strings.zoomPanHint : strings.zoomHint}</Text>
1124              </Box>
1125            )}
1126            {title}
1127            {raster === null ? (
1128              <Text dimColor>{strings.imageGone}</Text>
1129            ) : (
1130              <Box flexDirection="column" alignItems="center">
1131                <Raster key="cc-preview:view" columns={shape.window.columns} rows={shape.window.rows} cells={raster} />
1132              </Box>
1133            )}
1134          </Box>
1135        )
1136      }
1137      // Two rows of header, one to spare should the buttons wrap. A short pane (inline above the
1138      // prompt, a split terminal) would shrink the picture to a few cells: there it fills the
1139      // width instead, and the arrows scroll it.
1140      const fit = master === null ? null : fitBox(master.size, columns, rows - 3)
1141      const cells = master === null || fit === null ? null : fit.columns >= columns * 0.6 ? fit : fitBox(master.size, columns, 255)
1142      const scrolls = cells !== null && cells.rows > rows - 3
1143      const picture =
1144        master === null || cells === null
1145          ? null
1146          : await drawPicture($, ui, master, cells, { mode: look, edge: null, key: 'cc-preview:view', alt: current.label })
1147      note($, `pane picture ${master === null ? 'no source' : picture === null ? 'none' : `${cells?.columns}x${cells?.rows}`}`)
1148      return (
1149        <Box flexDirection="column">
1150          {header}
1151          <Box flexDirection="row" columnGap={1}>
1152            {title}
1153            {scrolls && <Text dimColor>{strings.scroll}</Text>}
1154          </Box>
1155          {picture === null ? (
1156            <Text dimColor>{strings.imageGone}</Text>
1157          ) : (
1158            <Box flexDirection="column" alignItems="center">
1159              {picture}
1160            </Box>
1161          )}
1162        </Box>
1163      )
1164    }
1165
1166    const document = path === null ? strings.documentGone : await documentOf($, path)
1167    return (
1168      <Box flexDirection="column">
1169        {header}
1170        {title}
1171        {typeof document === 'string' ? (
1172          <Text dimColor>{document}</Text>
1173        ) : (
1174          document.chunks.map((text, i) => <Markdown key={`cc-preview:md:${i}`} text={text} />)
1175        )}
1176        {typeof document !== 'string' && document.isTruncated && <Text dimColor>{strings.truncated}</Text>}
1177      </Box>
1178    )
1179  }).catch(($, e, next) => {
1180    note($, `pane failed: ${next.error.kind} ${next.error.message ?? ''}`)
1181    const { Text } = $.ui.resolve(e)
1182    return <Text dimColor>{`cc-preview: ${next.error.kind === 'timeout' ? 'took too long' : (next.error.message ?? 'failed')}`}</Text>
1183  })
1184}
1185
hooks/blocks.ts 98 lines
1// Under mosh no terminal graphics get through: mosh keeps its own copy of the screen and sends
2// only text and colors. A picture still can, drawn in quadrant block characters: each cell holds
3// four pixels, two across and two down, in two colors (the foreground and the background), the
4// split of the four that loses least picked per cell.
5
6export type Pixels = { width: number; height: number; rgb: (x: number, y: number) => number }
7
8// By which of a cell's pixels take the foreground: top left 1, top right 2, bottom left 4,
9// bottom right 8. A mask and its complement are one split with the colors swapped, so the eight
10// masks without the bottom right cover every split; 0 is the cell in one color.
11const QUADRANTS = [0x2580, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b]
12
13/** A BMP's pixels (24 or 32 bits, top-down or bottom-up), as `sips`, `ffmpeg` and ImageMagick write it; null for any other. */
14export function parseBmp(base64: string): Pixels | null {
15  let bytes: Uint8Array
16  try {
17    bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0))
18  } catch {
19    return null
20  }
21  if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null
22  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
23  const offset = view.getUint32(10, true)
24  const width = view.getInt32(18, true)
25  const rawHeight = view.getInt32(22, true)
26  const bits = view.getUint16(28, true)
27  const compression = view.getUint32(30, true)
28  const height = Math.abs(rawHeight)
29  // Uncompressed, or 32-bit with the usual BGRA masks
30  if (width <= 0 || height === 0 || (bits !== 24 && bits !== 32) || (compression !== 0 && compression !== 3)) return null
31  const stride = Math.floor((bits * width + 31) / 32) * 4
32  if (offset + stride * height > bytes.length) return null
33  const step = bits / 8
34  const isBottomUp = rawHeight > 0
35  return {
36    width,
37    height,
38    rgb: (x, y) => {
39      const row = isBottomUp ? height - 1 - y : y
40      const at = offset + row * stride + x * step
41      return ((bytes[at + 2] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at] ?? 0)
42    },
43  }
44}
45
46function meanOf(quad: readonly number[], mask: number, isForeground: boolean): number | null {
47  let r = 0
48  let g = 0
49  let b = 0
50  let n = 0
51  for (let i = 0; i < 4; i++) {
52    if ((((mask >> i) & 1) === 1) !== isForeground) continue
53    const color = quad[i] ?? 0
54    r += color >> 16
55    g += (color >> 8) & 0xff
56    b += color & 0xff
57    n++
58  }
59  return n === 0 ? null : (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
60}
61
62function distance(a: number, b: number): number {
63  const dr = (a >> 16) - (b >> 16)
64  const dg = ((a >> 8) & 0xff) - ((b >> 8) & 0xff)
65  const db = (a & 0xff) - (b & 0xff)
66  return dr * dr + dg * dg + db * db
67}
68
69/** Raster cells for `pixels` drawn `columns` × `rows`, four pixels a cell; base64 of u32 triplets. */
70export function quadrants(pixels: Pixels, columns: number, rows: number): string {
71  const words = new DataView(new ArrayBuffer(columns * rows * 12))
72  const quad = [0, 0, 0, 0]
73  for (let y = 0; y < rows; y++) {
74    for (let x = 0; x < columns; x++) {
75      for (let i = 0; i < 4; i++) {
76        quad[i] = pixels.rgb(Math.min(pixels.width - 1, x * 2 + (i & 1)), Math.min(pixels.height - 1, y * 2 + (i >> 1)))
77      }
78      let best = { mask: 0, fg: 0, bg: 0, error: Infinity }
79      for (let mask = 0; mask < 8; mask++) {
80        // The bottom right pixel is always background here, so that side is never empty
81        const bg = meanOf(quad, mask, false) ?? 0
82        const fg = meanOf(quad, mask, true) ?? bg
83        let error = 0
84        for (let i = 0; i < 4; i++) error += distance(quad[i] ?? 0, ((mask >> i) & 1) === 1 ? fg : bg)
85        if (error < best.error) best = { mask, fg, bg, error }
86      }
87      const at = (y * columns + x) * 12
88      words.setUint32(at, QUADRANTS[best.mask] ?? 0x2580, true)
89      words.setUint32(at + 4, best.fg, true)
90      words.setUint32(at + 8, best.bg, true)
91    }
92  }
93  const bytes = new Uint8Array(words.buffer)
94  let binary = ''
95  for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
96  return btoa(binary)
97}
98
hooks/connection.ts 93 lines
1// Pictures are drawn with the kitty graphics protocol only where it is known to reach the
2// screen; anywhere else in characters, which every terminal draws. The terminal can't be asked
3// (its answer would land in Claude Code's own input), so the path to it is read instead.
4
5/** How the person reaches this session, and the terminal app at the end when it runs here. */
6export type Path = { via: 'mosh' | 'ssh' | 'local'; terminal: string | null }
7
8/** What Claude Code was told about the terminal, which decides whether it sends graphics at all. */
9export type TerminalEnv = { force: string | undefined; termProgram: string | undefined; term: string | undefined }
10
11export type Process = { pid: number; ppid: number; tty: string; name: string }
12
13// Servers that outlive the connection that started them: the person may since have attached
14// from somewhere else
15const MULTIPLEXERS = new Set(['herdr', 'tmux', 'zellij', 'screen'])
16
17// Terminals that draw kitty graphics, by app, TERM_PROGRAM or TERM
18const KITTY = /ghostty|kitty|wezterm/i
19
20const hasTerminal = (process: Process) => process.tty !== '??' && process.tty !== '?' && process.tty !== ''
21
22/** `ps -axo pid=,ppid=,tty=,comm=` as rows, each named by its command's file name up to a space or colon (`tmux: server` is tmux). */
23export function parseProcesses(stdout: string): Process[] {
24  const rows: Process[] = []
25  for (const line of stdout.split('\n')) {
26    const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line)
27    if (match === null) continue
28    const command = (match[4] ?? '').replace(/^.*\//, '')
29    rows.push({ pid: Number(match[1]), ppid: Number(match[2]), tty: match[3] ?? '', name: command.split(/[\s:]/)[0] ?? command })
30  }
31  return rows
32}
33
34/** The terminals of every multiplexer client: the ones whose last input decides which client is the person's. */
35export const clientTerminals = (processes: readonly Process[]) => [
36  ...new Set(processes.filter(process => MULTIPLEXERS.has(process.name) && hasTerminal(process)).map(process => process.tty)),
37]
38
39/** `stat` lines of `<seconds> /dev/<tty>` as last input by terminal. */
40export function parseInputTimes(stdout: string): Map<string, number> {
41  const times = new Map<string, number>()
42  for (const match of stdout.matchAll(/^(\d+)\s+\/dev\/(\S+)\s*$/gm)) times.set(match[2] ?? '', Number(match[1]))
43  return times
44}
45
46/**
47 * The path from `start` to the person: the first mosh-server or sshd above it, else the app at
48 * the top (the local terminal). At a multiplexer's server the walk goes on from the client whose
49 * terminal had input last, the one the person types in now, rather than whichever started it.
50 */
51export function pathOf(processes: readonly Process[], start: number, lastInput: ReadonlyMap<string, number>): Path {
52  const byPid = new Map(processes.map(process => [process.pid, process]))
53  const walked = new Set<number>()
54  let at = byPid.get(start)
55  let top: Process | undefined
56  while (at !== undefined && at.pid > 1 && !walked.has(at.pid)) {
57    walked.add(at.pid)
58    top = at
59    if (at.name === 'mosh-server') return { via: 'mosh', terminal: null }
60    if (at.name.startsWith('sshd')) return { via: 'ssh', terminal: null }
61    if (MULTIPLEXERS.has(at.name) && !hasTerminal(at)) {
62      const name = at.name
63      let newest: Process | undefined
64      for (const client of processes) {
65        if (client.name !== name || !hasTerminal(client) || walked.has(client.pid)) continue
66        if (newest === undefined || (lastInput.get(client.tty) ?? 0) > (lastInput.get(newest.tty) ?? 0)) newest = client
67      }
68      if (newest !== undefined) {
69        at = newest
70        continue
71      }
72    }
73    at = byPid.get(at.ppid)
74  }
75  return { via: 'local', terminal: top?.name ?? null }
76}
77
78/**
79 * Whether kitty graphics reach the screen: Claude Code sends them (forced, or told its terminal
80 * draws them), no mosh stands between, and a local terminal is one that draws them. Across ssh
81 * the far terminal can't be named from here, so what Claude Code was told stands.
82 */
83export function drawsGraphics(path: Path, env: TerminalEnv): boolean {
84  const isForced = env.force !== undefined && env.force !== '' && env.force !== '0' && env.force.toLowerCase() !== 'false'
85  const isSent = isForced || KITTY.test(env.termProgram ?? '') || KITTY.test(env.term ?? '')
86  if (!isSent || path.via === 'mosh') return false
87  if (path.via === 'local' && path.terminal !== null) return KITTY.test(path.terminal)
88  return true
89}
90
91/** The path in a word, for the pane's mode button: `mosh`, `ssh`, or the local terminal. */
92export const pathLabel = (path: Path) => (path.via === 'local' ? (path.terminal ?? 'local') : path.via)
93
hooks/conversation.ts 142 lines
1import { cleanPath, pathsIn } from './paths'
2
3// What the conversation holds, read from `$.session.messages({ as: 'api' })` (or a row
4// `session.append` hands over): blocks as the Messages API spells them, media inline.
5export type Block = { readonly type: string; readonly [field: string]: unknown }
6export type Message = { readonly role: string; readonly content: readonly Block[] }
7
8/** An image the conversation holds as bytes: a paste, or a tool's picture (a screenshot). */
9export type Picture = { mediaType: string; base64: string; label: string; detail: string }
10
11/** One thing the conversation shows, in the order it first appears. */
12export type Item = { type: 'path'; raw: string } | { type: 'picture'; picture: Picture }
13
14/** A prompt the person sent with images: its words, as its row shows them, and its pictures. */
15export type PromptImages = { text: string; pictures: Picture[] }
16
17export type Scanned = { items: Item[]; prompts: PromptImages[] }
18
19/** The captions' words, in the person's language. */
20export type Words = { pasted: string; toolResult: string }
21
22// Text the engine adds beside what was written; a reminder can quote whole files full of paths
23const INJECTED = /<(system-reminder|local-command-stdout|local-command-caveat)>[\s\S]*?<\/\1>/g
24// Inputs that name a file
25const PATH_KEYS = ['file_path', 'notebook_path', 'path', 'filePath', 'output_path', 'outputPath', 'filename']
26// A Read's result holds the file's picture, but the file itself is the better source
27const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
28
29const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
30
31export const textOf = (blocks: readonly Block[]) =>
32  blocks
33    .filter(block => block.type === 'text' && typeof block.text === 'string')
34    .map(block => block.text as string)
35    .join('\n')
36
37/** The bytes of an image block in either spelling: the Messages API's `source`, or MCP's `data` and `mimeType`. */
38export function imageOf(block: unknown): { mediaType: string; base64: string } | null {
39  if (!isRecord(block) || block.type !== 'image') return null
40  const source = block.source
41  if (isRecord(source) && typeof source.data === 'string' && source.data !== '') {
42    return { mediaType: String(source.media_type ?? ''), base64: source.data }
43  }
44  if (typeof block.data === 'string' && block.data !== '') return { mediaType: String(block.mimeType ?? ''), base64: block.data }
45  return null
46}
47
48/** Every image block in a tool's result, however deep it sits (an MCP result nests them in `content`). */
49export function imagesIn(value: unknown, depth = 0): { mediaType: string; base64: string }[] {
50  if (depth > 4) return []
51  const own = imageOf(value)
52  if (own !== null) return [own]
53  if (Array.isArray(value)) return value.flatMap(item => imagesIn(item, depth + 1))
54  if (isRecord(value) && Array.isArray(value.content)) return imagesIn(value.content, depth + 1)
55  return []
56}
57
58/** The image and markdown files a tool call names in its input. */
59export function toolPaths(tool: string, input: unknown): string[] {
60  if (!isRecord(input)) return []
61  const found: string[] = []
62  for (const key of PATH_KEYS) {
63    const value = input[key]
64    const path = typeof value === 'string' ? cleanPath(value) : null
65    if (path !== null && !found.includes(path)) found.push(path)
66  }
67  if (tool === 'Bash' && typeof input.command === 'string') {
68    for (const path of pathsIn(input.command)) if (!found.includes(path)) found.push(path)
69  }
70  return found
71}
72
73/** `mcp__playwright__browser_take_screenshot` reads as `browser_take_screenshot`. */
74export const shortTool = (tool: string) => tool.replace(/^mcp__.*?__/, '')
75
76/** The labels of a prompt's pasted images: `Image #n` from its tags when they pair one to one. */
77function pasteLabels(text: string, count: number): string[] {
78  const tags = [...text.matchAll(/\[Image #(\d+)\]/g)].map(match => match[1])
79  return Array.from({ length: count }, (_, i) => (tags.length === count ? `Image #${tags[i]}` : `Pasted image ${i + 1}`))
80}
81
82/** A prompt's words for a caption: tags dropped, one line, short. */
83function gist(text: string): string {
84  const words = text.replace(/\[Image #\d+\]/g, '').replace(/\s+/g, ' ').trim()
85  return words.length > 40 ? `${words.slice(0, 39)}…` : words
86}
87
88/** The pictures of a prompt row: its top-level image blocks. */
89export function promptImages(content: readonly Block[], words: Words): PromptImages | null {
90  const images = content.map(imageOf).filter((image): image is { mediaType: string; base64: string } => image !== null)
91  if (images.length === 0) return null
92  // A request folds the engine's reminders into the prompt's message; the row shows only the words
93  const text = textOf(content).replace(INJECTED, '').trim()
94  const labels = pasteLabels(text, images.length)
95  const said = gist(text)
96  return {
97    text,
98    pictures: images.map((image, i) => ({ ...image, label: labels[i] ?? 'Pasted image', detail: said === '' ? words.pasted : `${words.pasted} · ${said}` })),
99  }
100}
101
102/** Every image and markdown file the conversation names or shows, oldest first. */
103export function scan(messages: readonly Message[], words: Words): Scanned {
104  const items: Item[] = []
105  const prompts: PromptImages[] = []
106  const calls = new Map<string, { tool: string; input: unknown }>()
107  const addPaths = (paths: string[]) => {
108    for (const raw of paths) items.push({ type: 'path', raw })
109  }
110
111  for (const message of messages) {
112    const content = Array.isArray(message.content) ? message.content : []
113    if (message.role === 'assistant') {
114      for (const block of content) {
115        if (block.type === 'text' && typeof block.text === 'string') addPaths(pathsIn(block.text))
116        if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
117          calls.set(block.id, { tool: block.name, input: block.input })
118          addPaths(toolPaths(block.name, block.input))
119        }
120      }
121      continue
122    }
123    const prompt = promptImages(content, words)
124    if (prompt !== null) {
125      prompts.push(prompt)
126      for (const picture of prompt.pictures) items.push({ type: 'picture', picture })
127    }
128    for (const block of content) {
129      if (block.type === 'text' && typeof block.text === 'string') addPaths(pathsIn(block.text.replace(INJECTED, '')))
130      if (block.type !== 'tool_result') continue
131      const call = typeof block.tool_use_id === 'string' ? calls.get(block.tool_use_id) : undefined
132      if (call === undefined || FILE_TOOLS.has(call.tool)) continue
133      const images = imagesIn(block.content)
134      images.forEach((image, i) => {
135        const label = images.length > 1 ? `${shortTool(call.tool)} ${i + 1}` : shortTool(call.tool)
136        items.push({ type: 'picture', picture: { ...image, label, detail: words.toolResult } })
137      })
138    }
139  }
140  return { items, prompts }
141}
142
hooks/i18n.ts 127 lines
1// Adapted from cc-image-view (https://github.com/GGGODLIN/cc-mod-image-view), MIT; see NOTICE.
2export type Locale = 'en' | 'zh-TW'
3
4export type Strings = {
5  noPreview: string
6  sentButton: (n: number) => string
7  zoom: string
8  open: string
9  more: (n: number) => string
10  paneTitle: string
11  listTitle: string
12  empty: string
13  images: string
14  markdown: string
15  prev: string
16  next: string
17  all: string
18  copy: string
19  refresh: string
20  blocks: string
21  scroll: string
22  zoomHint: string
23  zoomPanHint: string
24  graphics: string
25  imageGone: string
26  documentGone: string
27  documentTooBig: string
28  documentUnreadable: string
29  truncated: string
30  notPlaced: (reason: string) => string
31  notFound: (path: string) => string
32  terminalOnly: string
33  inPrompt: string
34  pasted: string
35  toolResult: string
36}
37
38const STRINGS: Record<Locale, Strings> = {
39  en: {
40    noPreview: 'no preview',
41    sentButton: n => `img #${n}`,
42    zoom: '⤢ Zoom',
43    open: '⤢ Open',
44    more: n => `+${n} more`,
45    paneTitle: 'Preview',
46    listTitle: 'Conversation artifacts',
47    empty: 'No images or markdown files in this conversation yet.',
48    images: 'Images',
49    markdown: 'Markdown',
50    prev: '‹ Prev',
51    next: 'Next ›',
52    all: '☰ All',
53    copy: 'Copy path',
54    refresh: '↻ Refresh',
55    blocks: '▦ Blocks',
56    scroll: '↑↓ scroll',
57    zoomHint: 'i/o zoom',
58    zoomPanHint: 'i/o zoom · h j k l move',
59    graphics: '▣ Graphics',
60    imageGone: "This image can't be shown: it is gone, or no converter could make a PNG of it.",
61    documentGone: 'This file is gone.',
62    documentTooBig: 'This file is over 4 MiB, too big to show here.',
63    documentUnreadable: 'This file could not be read.',
64    truncated: '… the rest of this file is not shown.',
65    notPlaced: reason => `Preview pane is waiting for room: ${reason}`,
66    notFound: path => `Nothing to preview at ${path}: no image or markdown file there.`,
67    terminalOnly: 'The preview pane draws in the terminal.',
68    inPrompt: 'in the prompt',
69    pasted: 'pasted',
70    toolResult: 'tool result',
71  },
72  'zh-TW': {
73    noPreview: '無法預覽',
74    // CJK, not an emoji: a CJK glyph is two cells on every terminal, so the card offsets add up
75    sentButton: n => `圖 #${n}`,
76    zoom: '⤢ 放大',
77    open: '⤢ 開啟',
78    more: n => `另外 ${n} 個`,
79    paneTitle: '預覽',
80    listTitle: '對話中的檔案',
81    empty: '這段對話還沒有圖片或 Markdown 檔案。',
82    images: '圖片',
83    markdown: 'Markdown',
84    prev: '‹ 上一個',
85    next: '下一個 ›',
86    all: '☰ 全部',
87    copy: '複製路徑',
88    refresh: '↻ 重新整理',
89    blocks: '▦ 色塊',
90    scroll: '↑↓ 捲動',
91    zoomHint: 'i/o 縮放',
92    zoomPanHint: 'i/o 縮放 · h j k l 移動',
93    graphics: '▣ 圖形',
94    imageGone: '無法顯示這張圖片:檔案已不在,或沒有轉檔工具能轉成 PNG。',
95    documentGone: '檔案已不在。',
96    documentTooBig: '檔案超過 4 MiB,太大無法在這裡顯示。',
97    documentUnreadable: '無法讀取這個檔案。',
98    truncated: '…其餘內容未顯示。',
99    notPlaced: reason => `預覽窗格等待空間:${reason}`,
100    notFound: path => `${path} 沒有可預覽的圖片或 Markdown 檔案。`,
101    terminalOnly: '預覽窗格只在終端機中顯示。',
102    inPrompt: '輸入框中',
103    pasted: '貼上',
104    toolResult: '工具結果',
105  },
106}
107
108export const stringsFor = (locale: Locale): Strings => STRINGS[locale]
109
110const CHINESE = /中文|漢語|汉语|華語|华语|國語|国语|chinese|mandarin|^zh(?:[-_.\s]|$)/i
111const ENGLISH = /英文|英語|english|^en(?:[-_.\s]|$)/i
112
113/**
114 * The UI language: the mod's own setting when it names one, then Claude Code's free-text
115 * `language` setting, then LC_ALL / LANG; English when none of them says. Any Chinese maps to
116 * Traditional Chinese, the only Chinese the mod ships.
117 */
118export function pickLocale(input: { option: unknown; claudeLanguage: unknown; envLang: string | undefined }): Locale {
119  if (input.option === 'en' || input.option === 'zh-TW') return input.option
120  if (typeof input.claudeLanguage === 'string') {
121    const language = input.claudeLanguage.trim()
122    if (CHINESE.test(language)) return 'zh-TW'
123    if (ENGLISH.test(language)) return 'en'
124  }
125  return /^zh/i.test(input.envLang ?? '') ? 'zh-TW' : 'en'
126}
127
hooks/layout.ts 159 lines
1// Adapted from cc-image-view (https://github.com/GGGODLIN/cc-mod-image-view), MIT; see NOTICE.
2export type Size = { width: number; height: number }
3export type Cells = { columns: number; rows: number }
4
5const TILE_ROWS = 6
6const MAX_COLUMNS = 32
7const MIN_COLUMNS = 4
8// A terminal cell is about twice as tall as it is wide.
9const CELL_ASPECT = 2
10// Used when the size is unknown.
11const FALLBACK: Size = { width: 16, height: 10 }
12// Each tile adds a border on every side and a button row under the picture.
13const TILE_CHROME_ROWS = 3
14const TILE_CHROME_COLUMNS = 2
15const GAP = 1
16
17/** The distinct image numbers a draft references, in the order they first appear. */
18export function imageNumbers(draft: string): number[] {
19  const seen = new Set<number>()
20  for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
21  return [...seen]
22}
23
24/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
25export function pngSize(base64: string): Size | null {
26  // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
27  let head: Uint8Array
28  try {
29    head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
30  } catch {
31    return null
32  }
33  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
34  if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
35  const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
36  const width = view.getUint32(16)
37  const height = view.getUint32(20)
38  return width > 0 && height > 0 ? { width, height } : null
39}
40
41/** How many bytes a base64 string decodes to. */
42export function decodedLength(base64: string): number {
43  const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0
44  return Math.floor((base64.length * 3) / 4) - padding
45}
46
47/** A picture box `rows` tall (at most `maxColumns` wide) that keeps the picture's aspect ratio. */
48export function fitCells(size: Size | null, tileRows = TILE_ROWS, maxColumns = MAX_COLUMNS): Cells {
49  const { width, height } = size ?? FALLBACK
50  let rows = tileRows
51  let columns = Math.round((rows * CELL_ASPECT * width) / height)
52  if (columns > maxColumns) {
53    columns = maxColumns
54    rows = Math.max(1, Math.round((maxColumns * height) / (CELL_ASPECT * width)))
55  }
56  return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
57}
58
59/** The largest box inside `maxColumns` × `maxRows` that keeps the picture's aspect ratio; Image caps both at 255. */
60export function fitBox(size: Size | null, maxColumns: number, maxRows: number): Cells {
61  const { width, height } = size ?? FALLBACK
62  const columnsCap = Math.max(1, Math.min(255, maxColumns))
63  const rowsCap = Math.max(1, Math.min(255, maxRows))
64  const columns = Math.round((rowsCap * CELL_ASPECT * width) / height)
65  if (columns <= columnsCap) return { columns: Math.max(1, columns), rows: rowsCap }
66  return { columns: columnsCap, rows: Math.max(1, Math.round((columnsCap * height) / (CELL_ASPECT * width))) }
67}
68
69/**
70 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
71 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
72 */
73export function fitRow(
74  sizes: readonly (Size | null)[],
75  maxRows: number,
76  bodyColumns: number,
77  chrome: Cells = { columns: TILE_CHROME_COLUMNS, rows: TILE_CHROME_ROWS },
78): Cells[] {
79  const tallest = Math.max(1, Math.min(TILE_ROWS, maxRows - chrome.rows))
80  for (let tileRows = tallest; tileRows > 1; tileRows--) {
81    const cells = sizes.map(size => fitCells(size, tileRows))
82    const width = cells.reduce((sum, c) => sum + c.columns + chrome.columns, 0) + GAP * (cells.length - 1)
83    if (width <= bodyColumns) return cells
84  }
85  return sizes.map(size => fitCells(size, 1))
86}
87
88// East Asian wide and fullwidth ranges a label may use; everything else here is one cell.
89const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/
90
91/** Terminal cells a string takes. */
92export function cellWidth(text: string): number {
93  let width = 0
94  for (const char of text) width += WIDE.test(char) ? 2 : 1
95  return width
96}
97
98/** `text` cut to `max` cells, the middle dropped so a file keeps its extension. */
99export function fitLabel(text: string, max: number): string {
100  if (cellWidth(text) <= max) return text
101  const chars = [...text]
102  const tail = chars.slice(-Math.min(8, Math.floor(max / 2))).join('')
103  let head = ''
104  for (const char of chars) {
105    if (cellWidth(head + char) + cellWidth(tail) + 1 > max) break
106    head += char
107  }
108  return `${head}…${tail}`
109}
110
111/** Where each button of a row starts: the terminal draws `[ label ]`, one cell apart. */
112export function buttonOffsets(labels: readonly string[]): number[] {
113  const offsets: number[] = []
114  let at = 0
115  for (const label of labels) {
116    offsets.push(at)
117    at += cellWidth(label) + 4 + GAP
118  }
119  return offsets
120}
121
122export const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(Math.max(low, high), value))
123
124// A zoomed picture is sampled to a BMP of 3 bytes a pixel, read whole: under $.fs.read's 4 MiB
125export const MAX_ZOOM_PIXELS = 1_200_000
126
127/**
128 * The zoom scales of a picture fitted in `fit` cells of quadrant blocks, two pixels a cell each
129 * way: 1 is the fit, then doubling, up to the scale that gives each pixel of the picture a pixel
130 * of the blocks' across (100%), or as near as the pixel cap allows.
131 */
132export function zoomLevels(size: Size, fit: Cells): number[] {
133  const oneToOne = size.width / (fit.columns * 2)
134  const capped = Math.sqrt(MAX_ZOOM_PIXELS / (fit.columns * 2 * fit.rows * 2))
135  const most = Math.min(oneToOne, capped)
136  const levels = [1]
137  for (let scale = 2; scale < most; scale *= 2) levels.push(scale)
138  if (most > (levels.at(-1) ?? 1) * 1.1) levels.push(most)
139  return levels
140}
141
142/** A zoomed picture: all its cells, and the window of them a box shows. */
143export type Zoomed = { full: Cells; window: Cells }
144
145export function zoomed(fit: Cells, box: Cells, scale: number): Zoomed {
146  const full = { columns: Math.max(1, Math.round(fit.columns * scale)), rows: Math.max(1, Math.round(fit.rows * scale)) }
147  return { full, window: { columns: Math.min(box.columns, full.columns), rows: Math.min(box.rows, full.rows) } }
148}
149
150/** Where the window goes when the zoom changes from `from` to `to`, so what was in its middle stays there. */
151export function keepCenter(at: { x: number; y: number }, from: Zoomed, to: Zoomed): { x: number; y: number } {
152  const middleX = (at.x + from.window.columns / 2) / from.full.columns
153  const middleY = (at.y + from.window.rows / 2) / from.full.rows
154  return {
155    x: clamp(Math.round(middleX * to.full.columns - to.window.columns / 2), 0, to.full.columns - to.window.columns),
156    y: clamp(Math.round(middleY * to.full.rows - to.window.rows / 2), 0, to.full.rows - to.window.rows),
157  }
158}
159
hooks/markdown.ts 63 lines
1// A Markdown element holds at most 10000 characters, tab and newline its only control characters.
2// A longer file is drawn as several, cut between lines, and a code fence open at a cut is closed
3// there and opened again in the next piece so both halves still draw as code.
4export const CHUNK_CHARS = 9_000
5export const MAX_CHUNKS = 40
6
7const FENCE = /^ {0,3}(`{3,}|~{3,})/
8
9export type Chunked = { chunks: string[]; isTruncated: boolean }
10
11function clean(text: string): string {
12  const unified = text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
13  // Front matter would draw as a rule and loose text; show it as the YAML it is
14  const front = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(unified)
15  return front === null ? unified : `\`\`\`yaml\n${front[1]}\n\`\`\`\n${unified.slice(front[0].length)}`
16}
17
18/** Splits `text` into pieces a Markdown element draws; at most `maxChunks`, the rest dropped. */
19export function markdownChunks(text: string, limit = CHUNK_CHARS, maxChunks = MAX_CHUNKS): Chunked {
20  const chunks: string[] = []
21  let lines: string[] = []
22  let size = 0
23  let fence: { opener: string; marker: string } | null = null
24  let isTruncated = false
25
26  const flush = () => {
27    if (lines.length === 0) return
28    if (fence !== null) lines.push(fence.marker)
29    chunks.push(lines.join('\n'))
30    lines = fence === null ? [] : [fence.opener]
31    size = lines.reduce((sum, line) => sum + line.length + 1, 0)
32  }
33
34  for (const line of clean(text).split('\n')) {
35    if (chunks.length >= maxChunks) {
36      isTruncated = true
37      break
38    }
39    // A line longer than a piece is cut where it must be
40    const pieces: string[] = []
41    for (let at = 0; at < Math.max(1, line.length); at += limit / 2) pieces.push(line.slice(at, at + limit / 2))
42    for (const piece of pieces) {
43      const reserve = fence === null ? 0 : fence.marker.length + 1
44      if (size + piece.length + 1 + reserve > limit) flush()
45      lines.push(piece)
46      size += piece.length + 1
47    }
48    const opened = FENCE.exec(line)
49    if (opened !== null) {
50      const marker = opened[1] ?? '```'
51      const trimmed = line.trim()
52      if (fence === null) fence = { opener: line, marker }
53      else if (trimmed.length >= fence.marker.length && [...trimmed].every(char => char === fence?.marker[0])) fence = null
54    } else if (fence === null && line.trim() === '' && size > limit * 0.8) {
55      // Near the limit, a blank line is a kinder place to cut than wherever the limit falls
56      flush()
57    }
58  }
59  if (chunks.length < maxChunks) flush()
60  else if (lines.length > 0 && lines.some(line => line.trim() !== '')) isTruncated = true
61  return { chunks: chunks.filter(chunk => chunk.trim() !== ''), isTruncated }
62}
63
hooks/media.ts 103 lines
1import type { Size } from './layout'
2
3// Every picture is handed to the terminal as PNG bytes, never as a file name: over ssh the
4// terminal runs on another machine and cannot read this one's files. `Image` takes at most
5// 2 MiB of PNG, so anything else (another format, a bigger PNG) is made into a PNG that fits.
6export const MAX_PNG_BYTES = 2 * 1024 * 1024
7
8/** An image as a PNG file on this machine: the source itself, or a converted copy. */
9export type Master = { path: string; size: Size; bytes: number }
10
11export const PNG = /\.png$/i
12export const CONVERTIBLE = /\.(jpe?g|gif|webp)$/i
13export const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
14
15// Tried in order, 10 s each: sips ships with macOS, the others are common on Linux. Only these
16// formats, only the first frame, file input only: a converter is a trust boundary.
17// Adapted from cc-image-view (https://github.com/GGGODLIN/cc-mod-image-view), MIT; see NOTICE.
18export const CONVERTERS = (src: string, out: string): string[][] => [
19  ['sips', '-s', 'format', 'png', src, '--out', out],
20  ['ffmpeg', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file', '-i', src, '-frames:v', '1', out],
21  ['magick', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, out],
22  ['convert', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, out],
23]
24
25/** Commands that shrink a PNG to `edge` on its longest side; only ever asked to shrink, as sips -Z would enlarge too. */
26export const RESIZERS = (src: string, out: string, edge: number, size: Size): string[][] => {
27  const scale = edge / Math.max(size.width, size.height)
28  const width = Math.max(1, Math.round(size.width * scale))
29  const height = Math.max(1, Math.round(size.height * scale))
30  return [
31    ['sips', '-Z', String(edge), src, '--out', out],
32    ['ffmpeg', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file', '-i', src, '-vf', `scale=${width}:${height}`, out],
33    ['magick', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', src, '-resize', `${width}x${height}!`, out],
34    ['convert', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', src, '-resize', `${width}x${height}!`, out],
35  ]
36}
37
38/** The longest side of the next try, shrinking by the square root of how far over the cap `bytes` is, with room to spare. */
39export const shrunkEdge = (edge: number, bytes: number) => Math.floor(edge * Math.sqrt((MAX_PNG_BYTES * 0.8) / bytes))
40
41// The copies are someone's pictures. A folder is only as private as the one holding it: if
42// another account can rename entries in the temp root, it can swap our folder for its own. So the
43// root must be ours, not a symlink, writable by no one else, and sit in a parent that is either
44// closed to others or sticky; then both folders are made ours and 700. Run on every write, which
45// also recreates a folder someone cleared.
46// Adapted from cc-image-view (https://github.com/GGGODLIN/cc-mod-image-view), MIT; see NOTICE.
47export const PRIVATE_DIRS = [
48  'umask 077',
49  'r="$1"; p=$(dirname "$r")',
50  '[ -d "$r" ] && [ ! -L "$r" ] && [ -O "$r" ] || exit 1',
51  '[ -z "$(find "$r" -maxdepth 0 \\( -perm -0020 -o -perm -0002 \\))" ] || exit 1',
52  '[ -z "$(find "$p" -maxdepth 0 \\( -perm -0020 -o -perm -0002 \\) ! -perm -1000)" ] || exit 1',
53  'for d in "$2" "$3"; do mkdir -p "$d" && [ ! -L "$d" ] && [ -O "$d" ] && chmod 700 "$d" || exit 1; done',
54].join('; ')
55
56/** A short stable name for `text`, to name scratch files and blobs by. */
57export async function digest(text: string): Promise<string> {
58  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
59  return Array.from(new Uint8Array(hash), byte => byte.toString(16).padStart(2, '0'))
60    .join('')
61    .slice(0, 20)
62}
63
64/** Commands that write `src` as a `width` × `height` BMP, the pixels the block drawing reads. */
65export const BMP_CONVERTERS = (src: string, out: string, width: number, height: number): string[][] => [
66  ['sips', '-s', 'format', 'bmp', '-z', String(height), String(width), src, '--out', out],
67  ['ffmpeg', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file', '-i', src, '-frames:v', '1', '-vf', `scale=${width}:${height}`, '-pix_fmt', 'bgr24', out],
68  ['magick', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, '-resize', `${width}x${height}!`, `BMP3:${out}`],
69  ['convert', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, '-resize', `${width}x${height}!`, `BMP3:${out}`],
70]
71
72/**
73 * A cache of loads by key that keeps the `limit` used last; a load that comes to null is
74 * forgotten, so the next ask tries again.
75 */
76export function memo<T>(limit: number) {
77  const entries = new Map<string, Promise<T | null>>()
78  return (key: string, load: () => Promise<T | null>): Promise<T | null> => {
79    const hit = entries.get(key)
80    if (hit !== undefined) {
81      entries.delete(key)
82      entries.set(key, hit)
83      return hit
84    }
85    const loading = load().then(
86      value => {
87        if (value === null) entries.delete(key)
88        return value
89      },
90      () => {
91        entries.delete(key)
92        return null
93      },
94    )
95    entries.set(key, loading)
96    for (const old of entries.keys()) {
97      if (entries.size <= limit) break
98      entries.delete(old)
99    }
100    return loading
101  }
102}
103
hooks/paths.ts 96 lines
1export type ArtifactKind = 'image' | 'markdown'
2
3const IMAGE = /\.(png|jpe?g|gif|webp)$/i
4const MARKDOWN = /\.(md|markdown|mdx)$/i
5// Most text names no such file; this cheap test skips the full scan for it
6const ANY = /\.(png|jpe?g|gif|webp|md|markdown|mdx)\b/i
7
8export function kindOf(path: string): ArtifactKind | null {
9  if (IMAGE.test(path)) return 'image'
10  if (MARKDOWN.test(path)) return 'markdown'
11  return null
12}
13
14export const mentionsArtifact = (text: string) => ANY.test(text)
15
16/** A path as written, cleaned of what surrounds it in prose; null when it is no image or markdown file. */
17export function cleanPath(raw: string): string | null {
18  let path = raw.trim()
19  if (/^file:\/\//i.test(path)) {
20    path = path.slice('file://'.length)
21    try {
22      path = decodeURIComponent(path)
23    } catch {
24      // keep it as written
25    }
26  } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
27    // A web address: never fetched
28    return null
29  }
30  path = path.replace(/^[<([{]+/, '').replace(/[>)\]},.;:!?]+$/, '')
31  // `plan.md:12` or `plan.md:12-20` names lines of the file
32  path = path.replace(/:\d+(?:[:-]\d+)?$/, '')
33  if (path === '' || path.length > 1024 || /[*?<>|\n\r\t]/.test(path)) return null
34  return kindOf(path) === null ? null : path
35}
36
37/**
38 * The image and markdown paths a text names, in order, each once: inside backticks or quotes
39 * (where a path may hold spaces) and as bare words. Nothing here touches the disk.
40 */
41export function pathsIn(text: string): string[] {
42  const found: string[] = []
43  const add = (raw: string) => {
44    const path = cleanPath(raw)
45    if (path !== null && !found.includes(path)) found.push(path)
46  }
47  if (!mentionsArtifact(text)) return found
48  const SPANS = /`([^`\n]+)`|"([^"\n]+)"|'([^'\n]+)'/g
49  let rest = text
50  for (const match of text.matchAll(SPANS)) {
51    const span = match[1] ?? match[2] ?? match[3] ?? ''
52    // Words in quotes are prose unless they look like a path
53    if (/\s/.test(span.trim()) && !span.includes('/')) continue
54    add(span)
55    // Its words are not read again one by one (`Screen Shot 1.png` names no `1.png`)
56    rest = rest.replace(match[0], ' ')
57  }
58  for (const match of rest.matchAll(/[^\s`'"()<>[\]{}]+/g)) add(match[0])
59  return found
60}
61
62/** `abs` with `.`, `..` and repeated slashes folded. */
63export function normalize(abs: string): string {
64  const parts: string[] = []
65  for (const part of abs.split('/')) {
66    if (part === '' || part === '.') continue
67    if (part === '..') parts.pop()
68    else parts.push(part)
69  }
70  return `/${parts.join('/')}`
71}
72
73/** An absolute path for one as written: `~/` under home, a relative one under cwd. */
74export function resolvePath(raw: string, cwd: string, home: string | undefined): string | null {
75  let path = raw
76  if (path === '~' || path.startsWith('~/')) {
77    if (home === undefined) return null
78    path = home + path.slice(1)
79  } else if (path.startsWith('~')) {
80    return null
81  }
82  if (!path.startsWith('/')) path = `${cwd}/${path}`
83  return normalize(path)
84}
85
86export const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
87
88/** Where a file sits, for a caption: relative to `root` when inside it, `~/` under home, else absolute. */
89export function folderOf(path: string, root: string, home: string | undefined): string {
90  const dir = path.slice(0, Math.max(1, path.lastIndexOf('/')))
91  if (dir === root) return './'
92  if (dir.startsWith(`${root}/`)) return `${dir.slice(root.length + 1)}/`
93  if (home !== undefined && dir.startsWith(`${home}/`)) return `~/${dir.slice(home.length + 1)}/`
94  return `${dir}/`
95}
96
types/index.d.ts 43 lines
1/** Where an artifact's content comes from: a file on this machine, or bytes the conversation holds. */
2export type ArtifactSource = { type: 'file'; path: string } | { type: 'blob'; id: string }
3
4/** One image or markdown file the preview pane can show. */
5export type Artifact = {
6  /** `file:<real path>`, `blob:<digest>` or `draft:<path>`; unique in the list. */
7  id: string
8  kind: 'image' | 'markdown'
9  /** The file's name, or `Image #n` for a paste. */
10  label: string
11  /** Where it is from: its folder, or `pasted · <the prompt's words>`. */
12  detail: string
13  source: ArtifactSource
14}
15
16/** How pictures are drawn: kitty graphics (kitty, Ghostty, WezTerm), or block characters, which every terminal and mosh carry. */
17export type PictureMode = 'graphics' | 'blocks'
18
19/** Where the docked pane's block picture is zoomed and moved to, for the artifact `id`. */
20export type ZoomView = { id: string; level: number; x: number; y: number }
21
22/** An image the prompt being typed refers to (`[Image #n]`) and its cached paste; null when none is found. */
23export type DraftImage = { n: number; path: string | null }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'cc-preview': {
28      /** What the conversation shows as of the last scan: images, then markdown, each newest first. */
29      artifacts: Artifact[]
30      /** What the pane shows; null shows the list. */
31      selected: Artifact | null
32      /** The draft's pasted images, for the band above the prompt. */
33      draft: DraftImage[]
34      /** How pictures are drawn now: the `pictures` option, or what the path to the screen was found to carry. */
35      mode: PictureMode
36      /** The path to the screen in a word: `mosh`, `ssh`, or the local terminal app. */
37      link: string
38      /** The docked pane's zoom: the level, and the window's top left in cells. */
39      zoom: ZoomView
40    }
41  }
42}
43