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

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.
[Image #n] tags, a row of thumbnails sits above the prompt. Press #n ⤢ under one to open it in the pane.[ img #n ] button. Hover it to see the picture; click it, or the card's ⤢ Zoom, to open it in the pane..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./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.
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 · ….
Every picture is sent to the terminal as bytes, never as a file name, so it shows when Claude Code runs on another machine.
▘▝▀▖▌▞▛, 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).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.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.
$.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.<temp dir>/<project>/<session>/images/.~/.claude/settings.json (or the one under CLAUDE_CONFIG_DIR) once, for the language.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.| Option | Values | Default |
|---|---|---|
pictures | auto, graphics, blocks | auto |
language | auto, en, zh-TW (auto follows Claude Code's language, then LC_ALL / LANG) | auto |
claude plugin validate .
claude plugin test .
tsc -p . # TypeScript 5.5 or later
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.
hooks/register.tsx 1185 lines1import { 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}
1185hooks/blocks.ts 98 lines1// 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}
98hooks/connection.ts 93 lines1// 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)
93hooks/conversation.ts 142 lines1import { 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}
142hooks/i18n.ts 127 lines1// 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}
127hooks/layout.ts 159 lines1// 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}
159hooks/markdown.ts 63 lines1// 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}
63hooks/media.ts 103 lines1import 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}
103hooks/paths.ts 96 lines1export 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}
96types/index.d.ts 43 lines1/** 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