See images, SVGs and PDF pages in Claude Code where they appear: pasted, read, written or linked

See images, SVGs and PDF pages in Claude Code where they appear: the screenshot you paste, the file Claude reads, the chart Claude writes, the image a tool returns, the picture a reply links to.

A real Claude Code session in CSM 2.127.0. The pictures are sharp in kitty, Ghostty and CSM 2.127.0 or later on macOS and Linux. Other terminals, tmux, and CSM on Windows get colored blocks (below).
Type this at the Claude Code prompt:
/plugin install lens --marketplace minhdevtree/claude-lens
Answer y to add the marketplace and pick a scope. lens works from the next message on.
claude plugin marketplace add minhdevtree/claude-lens
claude plugin install lens@claude-lens
Then run /reload-plugins in a session, or start a new one.
| Where Claude Code runs | What lens draws |
|---|---|
| kitty, Ghostty, CSM (Claude Session Manager) 2.127.0 or later on macOS and Linux | The picture itself (kitty graphics protocol) |
| CSM on Windows, CSM before 2.127.0 | Colored blocks in the finest detail: each cell is split 2x3 (sextants), its two best colors |
| tmux, iTerm2, WezTerm, Terminal.app, the VS Code terminal, other apps built on xterm.js | Colored blocks, each cell split 2x2 (quadrants) |
| Claude desktop app, VS Code extension, mobile | SVG files only, drawn by the app; other previews are left out |

The same three thumbnails at the same size in the three block details. Drawn from tmux captures, with each block glyph painted as the terminal's own block shapes, as CSM's terminal does.
Inside tmux, Claude Code uses 256 colors unless you set CLAUDE_CODE_TMUX_TRUECOLOR=1 (in your shell or in the env block of ~/.claude/settings.json). The blocks look much better with it.
| Source | Example | Shown |
|---|---|---|
| Pasted images | [Image #1] in your prompt | Above the prompt while you write, then under your message, labelled Image #1 as Claude Code names it |
| Files Claude reads | Read of a PNG, SVG or PDF | Under the "Read 1 file" row |
| Files Claude writes | Write or Edit of an SVG, a Bash command that makes out.png | Under the tool's result |
| Image links in replies | | Under the reply, labelled the way Claude Code draws the link: revenue by month (chart.png) |
| Images tools return | An MCP or browser tool's screenshot | Under the tool's result |
File types: PNG, JPEG, GIF, WebP, BMP, TIFF, HEIC, SVG and PDF (the first pages). Web links are never fetched.
/lens opens a gallery of everything lens has shown this session.
Mods run as a chain, in the order your ~/.claude/settings.json lists them under enabledPlugins, first on the outside. A mod that redraws a row as its own card without passing the row on, as claude-skins does for tool groups and prompts, keeps every mod listed after it from drawing on that row.
lens handles this on its own: at the end of each turn, any preview that its row did not get shows in the band above the prompt, under the line lens: previews from this turn (another mod drew their rows), until you send the next prompt. The /lens gallery always has everything.
To get those previews inline again, put lens@claude-lens before the other mod in enabledPlugins and start a new session:
"enabledPlugins": {
"lens@claude-lens": true,
"skins@hellosverre-mods": true
}
Every setting is a row in /config (type lens to filter):

| Row | Values | Default |
|---|---|---|
| Images, SVG, PDF | on / off per file type | on |
| Pasted images | above (over the prompt while you write, then under your message), inline, pane (gallery only), off | above |
| Files Claude reads, Files Claude writes, Image links in replies, Images tools return | inline, pane (gallery only), off | inline |
| Drawing | auto (pictures in kitty, Ghostty and CSM 2.127.0+, blocks elsewhere), kitty, blocks, text (file names only) | auto |
| Block detail | auto (sextants in CSM, quadrants elsewhere), sextant (2x3 per cell), quadrant (2x2), halfblock (1x2, what 0.1 drew) | auto |
| Thumbnail rows | 2 to 40 | 10 |
| PDF pages | 1 to 8 | 1 |
Quick switches for the session:
| Command | Does |
|---|---|
/lens | Opens the gallery |
/lens off, /lens on | Hides or shows every preview (remembered) |
/lens status | Shows the drawing mode, the settings and the converters found |
/lens clear | Empties the gallery |
sips and PDFKit, which ship with macOS.pdftoppm (poppler-utils) or mutool (mupdf-tools)rsvg-convert (librsvg2-bin) or ImageMagickmagick or convert)On Debian or Ubuntu: sudo apt install poppler-utils librsvg2-bin imagemagick.
/bin/sh).lens is local. It makes no network requests. It writes only to its cache folder, $TMPDIR/claude-lens, where it keeps the thumbnails and the pasted or returned images it previews. It reads the files it previews and, for the band above the prompt, Claude Code's own paste cache. claude plugin validate lists every call it makes.
Nothing appears. Run /lens status. If it says "off for now", run /lens on. If it lists no converters on Linux, install the packages above. Check that /plugin lists lens as active, or run /reload-plugins.
I see the file name in brackets, not a picture. Your terminal does not paint kitty graphics but Drawing is set to kitty. Set it to auto or blocks.
The blocks show as empty boxes or question marks. Your terminal has no glyphs for sextants (U+1FB00 and on): no font macOS ships has them, so Terminal.app and iTerm2 cannot draw them. Set Block detail to quadrant (or halfblock). auto only picks sextants inside CSM, whose terminal draws them itself.
The blocks look washed out. In tmux, set CLAUDE_CODE_TMUX_TRUECOLOR=1. Elsewhere, check that your terminal has truecolor (COLORTERM=truecolor).
A preview is missing for a big PDF. Claude Code refuses to Read a long PDF without a page range, and lens skips reads that failed. Files over 64 MB are not converted.
Previews show above the prompt, not under the rows. Another mod draws those rows without passing them on. See With other mods.
A row I scrolled past has no picture. On the main screen, finished rows are printed once. lens prepares each preview as soon as Claude names the file, so this is rare; the /lens gallery always has it.
See docs/DESIGN.md. In short: lens is a Claude Code mod. It notices images as Claude Code stores each row of the conversation, turns each file into a PNG thumbnail with the tools your OS has, and draws it under the row with Claude Code's Image element (kitty, Ghostty), or as colored blocks: each cell's pixels are split into the two colors that fit them best and the block glyph of that split.
git clone https://github.com/minhdevtree/claude-lens
cd claude-lens
claude --plugin-dir . # load it for one session; edits reload live
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # after one `claude --plugin-dir .`, which writes the API types
MIT. See LICENSE.
hooks/register.tsx 490 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import type { LensDraftImage, LensItem } from '../types'
5import {
6 MEDIA_EXT, RENDERERS, TOOL_NAMES, baseName, commandPaths, configOf, fitCells, hash, imageLinks, imageNumbers, kindOf, pastedLabels, linkLabel,
7 SAMPLES, mosaicCells, mosaicFor, packCells, paintsImages, parseBmp, parsePpm, pixelsPlan, pngSize, rowKey, textRows,
8} from './lib'
9import type { Config, Kind, Mosaic, Run, Size, Source } from './lib'
10
11type $ = EngineInterface
12
13const gallery = atom({ plugin: 'lens', key: 'gallery' } as const, [] as LensItem[])
14const draft = atom({ plugin: 'lens', key: 'draft' } as const, [] as LensDraftImage[])
15const muted = atom({ plugin: 'lens', key: 'muted' } as const, false)
16/** Ids of this turn's previews no row drew: another mod answered their row without passing it on. */
17const unplaced = atom({ plugin: 'lens', key: 'unplaced' } as const, [] as string[])
18
19/** Longest side of a thumbnail PNG, in pixels: enough for 40 rows of a high-density terminal. */
20const THUMB_PX = 800
21/** A file bigger than this is not handed to a converter. */
22const MAX_INPUT_BYTES = 64 * 1024 * 1024
23/** The newest previews the gallery keeps, and how many its pane draws (each Image holds one of 255 terminal ids). */
24const GALLERY_KEEP = 300
25const GALLERY_SHOWN = 12
26const POLL_MS = 250
27/** How long after a turn ends its rows have to draw their previews before the band above the prompt takes the rest. */
28const SETTLE_MS = 1500
29const READS = new Set(['Read'])
30const WRITES = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
31
32type Host = { tools: Set<string>; cache: string; kitty: boolean; csm: boolean; cwd: string; root: string; tmp: string }
33/** A thumbnail as colored blocks: packed `Raster` cells, or rows of colored text runs for sextants (beyond the BMP). */
34type Block = { kind: 'raster'; cells: string; columns: number; rows: number } | { kind: 'text'; rows: Run[][] }
35type Thumb = { png: string; size: Size } | { error: string }
36type Preview = { path: string; label: string; key: string; drawnAs?: string }
37
38let host: Promise<Host> | undefined
39const sizes = new Map<string, Size>()
40const cellCache = new Map<string, Block>()
41const bashCalls = new Map<string, { command: string; at: number }>()
42/** The transcript's width as the last draw saw it, so the collector can make cells of the size a row will ask for. */
43let lastColumns = 80
44/** What the inline hooks drew (`drawnKey`), and the previews this turn found, so the turn's end can tell what no row drew. */
45const drawn = new Set<string>()
46let turnIds: string[] = []
47const drawnKey = (path: string, owner = '') => `${path}|${owner}`
48let isPolling = false
49let isChecking = false
50let pasteDir: { sessionId: string; dir: string } | undefined
51
52async function setup($: $): Promise<Host> {
53 const found = await $.process.run(['/bin/sh', '-c', 'for t in "$@"; do command -v "$t" >/dev/null 2>&1 && echo "$t"; done', 'sh', ...TOOL_NAMES])
54 .catch(() => ({ stdout: '' }))
55 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
56 const kitty = paintsImages({
57 CLAUDE_CODE_FORCE_TERMINAL_IMAGES: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
58 TMUX: await $.env.get('TMUX'),
59 STY: await $.env.get('STY'),
60 TERM: await $.env.get('TERM'),
61 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
62 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
63 GHOSTTY_RESOURCES_DIR: await $.env.get('GHOSTTY_RESOURCES_DIR'),
64 })
65 const csm = (await $.env.get('CSM_SESSION')) !== undefined
66 return { tools: new Set(found.stdout.split('\n').filter(Boolean)), cache: `${tmp}/claude-lens`, kitty, csm, cwd: await $.session.cwd(), root: $.plugin.root, tmp }
67}
68
69function hostOf($: $): Promise<Host> {
70 host ??= setup($)
71 return host
72}
73
74async function run($: $, steps: readonly string[][]): Promise<string | undefined> {
75 for (const argv of steps) {
76 const ran = await $.process.run(argv, { timeoutMs: 20_000 }).catch((err: unknown) => ({ exitCode: -1, stderr: String(err) }))
77 if (ran.exitCode !== 0) return `${argv[0]} failed: ${ran.stderr.trim().split('\n')[0]?.slice(0, 120) ?? ''}`
78 }
79 return undefined
80}
81
82/**
83 * A PNG thumbnail of one page of `path` in the cache, made once per content (path, size, mtime).
84 *
85 * Only settled results are kept: a draw that is superseded has its calls aborted (its converter killed, its
86 * promise never settling), so in-flight work is never shared between draws. Each run writes a temporary file of
87 * its own and moves it into place, so a killed run leaves no half picture.
88 */
89async function thumbnail($: $, path: string, kind: Kind, page: number): Promise<Thumb> {
90 const env = await hostOf($)
91 const stat = await $.fs.stat(path).catch(() => undefined)
92 if (stat === undefined || stat.kind !== 'file') return { error: 'file not found' }
93 if (stat.size > MAX_INPUT_BYTES) return { error: `too big to preview (${Math.round(stat.size / 1048576)} MB)` }
94 const out = `${env.cache}/${hash(`${path}|${stat.size}|${stat.mtimeMs}|${page}|${THUMB_PX}`)}.png`
95 const known = sizes.get(out)
96 if (known) return { png: out, size: known }
97 if (!(await $.fs.exists(out))) {
98 const part = out.replace(/\.png$/, `.${(await $.clock.now()).toString(36)}.part.png`)
99 const steps = RENDERERS[kind](path, part, THUMB_PX, page, env.tools, env.root)
100 if (steps === undefined) return { error: `no ${kind} converter found (see the lens README)` }
101 await $.fs.write(`${env.cache}/.keep`, '')
102 const failed = await run($, [...steps, ['mv', '-f', part, out]])
103 if (failed !== undefined) return { error: failed }
104 }
105 const bytes = await $.fs.read(out, { as: 'bytes' }).catch(() => undefined)
106 const size = bytes && pngSize(bytes.base64)
107 if (!size) return { error: 'the converter made no picture' }
108 sizes.set(out, size)
109 return { png: out, size }
110}
111
112/**
113 * Colored-block cells for a thumbnail, `columns` x `rows` cells, each cell `SAMPLES[mode]` pixels fitted to the two
114 * colors and the glyph that draw them best; settled results kept.
115 */
116async function blockCells($: $, png: string, columns: number, rows: number, mode: Mosaic): Promise<Block | undefined> {
117 const key = `${png}|${columns}x${rows}|${mode}`
118 const known = cellCache.get(key)
119 if (known) return known
120 const env = await hostOf($)
121 const { x, y } = SAMPLES[mode]
122 const base = png.replace(/\.png$/, `-${columns * x}x${rows * y}`)
123 const plan = pixelsPlan(png, base, columns * x, rows * y, env.tools)
124 if (plan === undefined || (await run($, plan.steps)) !== undefined) return undefined
125 const read = await $.fs.read(`${base}.${plan.format}`, { as: 'bytes' }).catch(() => undefined)
126 if (read === undefined) return undefined
127 const bytes = Uint8Array.fromBase64(read.base64)
128 const cells = mosaicCells(plan.format === 'bmp' ? parseBmp(bytes) : parsePpm(bytes), mode)
129 const block: Block = mode === 'sextant' ? { kind: 'text', rows: textRows(cells) } : { kind: 'raster', ...packCells(cells) }
130 cellCache.set(key, block)
131 return block
132}
133
134/** Writes base64 bytes (a pasted or returned image) to the cache and answers the file's path. */
135async function saveBytes($: $, base64: string, mediaType: string): Promise<string | undefined> {
136 const env = await hostOf($)
137 const ext = MEDIA_EXT[mediaType]
138 if (ext === undefined) return undefined
139 const out = `${env.cache}/${hash(base64)}-${base64.length}.${ext}`
140 if (await $.fs.exists(out)) return out
141 await $.fs.write(`${out}.b64`, base64)
142 const ran = await $.process.run(['/bin/sh', '-c', 'base64 -d < "$1" > "$2" && rm -f "$1"', 'sh', `${out}.b64`, out]).catch(() => undefined)
143 return ran?.exitCode === 0 ? out : undefined
144}
145
146/**
147 * The tree for some previews on this surface: each a picture (kitty), colored blocks, or its name, under a dim
148 * label. `els` is the surface's element table.
149 */
150async function previews($: $, e: { surface: RenderSurface; viewport?: { columns: number }; props: unknown }, cfg: Config,
151 items: readonly Preview[], maxColumns: number, maxRows: number): Promise<RenderElement[]> {
152 const els = $.ui.resolve(e as never) as Record<string, (...args: never[]) => RenderElement>
153 const { Box, Text } = els as never as { Box: any; Text: any }
154 const env = await hostOf($)
155 const nodes: RenderElement[] = []
156 for (const item of items) {
157 const kind = kindOf(item.path)
158 if (kind === undefined || !cfg.kinds[kind]) continue
159 if (e.surface !== 'terminal') {
160 // Remote surfaces have no Image or Raster; an SVG can be drawn by their Svg element.
161 if (kind !== 'svg') continue
162 const { Svg } = els as never as { Svg: any }
163 const source = await $.fs.read(item.path).catch(() => '')
164 if (source.length > 0 && source.length <= 131072) {
165 nodes.push(<Box flexDirection="column"><Text dimColor>{item.label}</Text><Svg source={source} alt={item.label} width={320} /></Box>)
166 }
167 continue
168 }
169 const painter = cfg.painter === 'auto' ? (env.kitty ? 'kitty' : 'blocks') : cfg.painter
170 // Drawn the same on and off screen (`e.props.onScreen`): a row whose Image was swapped for its name off screen
171 // mounted a fresh Image each time it crossed back in, and Claude Code's fullscreen scroll pulled the view down.
172 if (painter === 'text') {
173 nodes.push(<Text dimColor>{` ${item.label}`}</Text>)
174 continue
175 }
176 const pages = kind === 'pdf' ? cfg.pdfPages : 1
177 const width = pageWidth(maxColumns, pages)
178 const row: RenderElement[] = []
179 for (let page = 1; page <= pages; page++) {
180 const t = await thumbnail($, item.path, kind, page)
181 if ('error' in t) {
182 if (page === 1) row.push(<Text dimColor>{`${item.label}: ${t.error}`}</Text>)
183 break
184 }
185 const cells = fitCells(t.size, maxRows, width)
186 if (painter === 'kitty') {
187 const { Image } = els as never as { Image: any }
188 row.push(<Image key={`${item.key}-${page}`} source={{ file: t.png, format: 'png' }} columns={cells.columns} rows={cells.rows} alt={`[${item.label}${pages > 1 ? ` p${page}` : ''}]`} />)
189 } else {
190 const { Raster } = els as never as { Raster: any }
191 const b = await blockCells($, t.png, cells.columns, cells.rows, mosaicFor(cfg.detail, env))
192 row.push(b === undefined ? <Text dimColor>{`${item.label}: no pixel converter found`}</Text>
193 : b.kind === 'raster' ? <Raster key={`${item.key}-${page}`} cells={b.cells} columns={b.columns} rows={b.rows} />
194 : <Box flexDirection="column">{b.rows.map(runs => <Text wrap="truncate">{runs.map(run => <Text color={run.fg} backgroundColor={run.bg}>{run.text}</Text>)}</Text>)}</Box>)
195 }
196 }
197 nodes.push(<Box flexDirection="column"><Text dimColor>{item.label}</Text><Box flexDirection="row" columnGap={1}>{row}</Box></Box>)
198 }
199 return nodes
200}
201
202const inlineColumns = (viewportColumns: number) => Math.min(120, viewportColumns - 8)
203const pageWidth = (maxColumns: number, pages: number) => Math.max(4, Math.floor((maxColumns - (pages - 1)) / pages))
204
205/** Draws `previews` under what the engine draws for this row, indented like a tool's output. */
206async function under($: $, e: any, next: (e: any) => Promise<RenderElement>, cfg: Config, items: readonly Preview[]): Promise<RenderElement> {
207 if (items.length === 0) return next(e)
208 const below = await next(e)
209 const { Box } = $.ui.resolve(e) as never as { Box: any }
210 lastColumns = e.viewport?.columns ?? lastColumns
211 const nodes = await previews($, e, cfg, items, inlineColumns(lastColumns), cfg.maxRows)
212 for (const item of items) if (item.drawnAs !== undefined) drawn.add(item.drawnAs)
213 if (nodes.length === 0) return below
214 return <Box flexDirection="column">{below}<Box flexDirection="column" paddingLeft={5}>{nodes}</Box></Box>
215}
216
217async function itemsFor($: $, cfg: Config, test: (item: LensItem) => boolean): Promise<Preview[]> {
218 const list = await read($, gallery)
219 return list.filter(item => cfg.where[item.source] === 'inline' || (item.source === 'pasted' && cfg.where.pasted === 'above'))
220 .filter(test).map(item => ({ path: item.path, label: item.label, key: hash(item.id), drawnAs: drawnKey(item.path, item.toolUseId ?? item.row) }))
221}
222
223async function addItems($: $, items: readonly LensItem[]) {
224 if (items.length === 0) return
225 await update($, gallery, list => {
226 const ids = new Set(list.map(item => item.id))
227 return [...list, ...items.filter(item => !ids.has(item.id))].slice(-GALLERY_KEEP)
228 })
229}
230
231/** Turns one stored row into previews: pasted images, files a tool reads or writes, image links, images a tool returns. */
232async function collect($: $, cfg: Config, e: any, uuid: string) {
233 const blocks: any[] = Array.isArray(e.message?.content) ? e.message.content : []
234 const env = await hostOf($)
235 const at = await $.clock.now()
236 const wants = (source: Source, path: string) => {
237 const kind = kindOf(path)
238 return cfg.where[source] !== 'off' && kind !== undefined && cfg.kinds[kind]
239 }
240 const found: LensItem[] = []
241 if (e.door === 'prompt') {
242 const images = blocks.filter(b => b.type === 'image' && b.source?.type === 'base64')
243 const labels = pastedLabels(blocks.filter(b => b.type === 'text').map(b => b.text).join('\n'), images.length)
244 for (const [i, b] of images.entries()) {
245 const path = await saveBytes($, b.source.data, b.source.media_type)
246 if (path && wants('pasted', path)) found.push({ id: `${uuid}:${i + 1}`, source: 'pasted', path, label: labels[i]!, row: rowKey(uuid), at })
247 }
248 }
249 if (e.door === 'response') {
250 for (const b of blocks) {
251 if (b.type === 'tool_use') {
252 const path = b.input?.file_path ?? b.input?.notebook_path
253 const source: Source | undefined = READS.has(b.name) ? 'read' : WRITES.has(b.name) ? 'written' : undefined
254 if (source && typeof path === 'string' && wants(source, path)) {
255 found.push({ id: `${b.id}:${path}`, source, path, label: baseName(path), toolUseId: b.id, at })
256 }
257 if (b.name === 'Bash' && typeof b.input?.command === 'string') bashCalls.set(b.id, { command: b.input.command, at })
258 }
259 if (b.type === 'text' && typeof b.text === 'string') {
260 for (const link of imageLinks(b.text, env.cwd)) {
261 if (wants('links', link.path)) found.push({ id: `${uuid}:${link.path}`, source: 'links', path: link.path, label: linkLabel(link), at })
262 }
263 }
264 }
265 }
266 if (e.door === 'tool-result') {
267 const tool = e.origin?.tool
268 for (const b of blocks) {
269 if (b.type !== 'tool_result') continue
270 const bash = bashCalls.get(b.tool_use_id)
271 if (bash) {
272 bashCalls.delete(b.tool_use_id)
273 for (const path of commandPaths(bash.command, env.cwd)) {
274 const stat = await $.fs.stat(path).catch(() => undefined)
275 // Only a file the command just made or changed, not one it merely read.
276 if (stat?.kind === 'file' && stat.mtimeMs >= bash.at - 2000 && wants('written', path)) {
277 found.push({ id: `${b.tool_use_id}:${path}`, source: 'written', path, label: baseName(path), toolUseId: b.tool_use_id, at })
278 }
279 }
280 }
281 if (READS.has(tool) || !Array.isArray(b.content)) continue
282 let n = 0
283 for (const c of b.content) {
284 if (c.type !== 'image' || c.source?.type !== 'base64') continue
285 n += 1
286 const path = await saveBytes($, c.source.data, c.source.media_type)
287 if (path && wants('toolImages', path)) {
288 found.push({ id: `${b.tool_use_id}:${n}`, source: 'toolImages', path, label: `${tool ?? 'tool'} image ${n}`, toolUseId: b.tool_use_id, at })
289 }
290 }
291 }
292 }
293 await addItems($, found)
294 turnIds.push(...found.map(item => item.id))
295 // Made here, where no draw can supersede the converters, so the row's first draw needs no process: on the
296 // main screen a finished row is printed once, and a draw whose converter was killed would print it bare.
297 const isBlocks = cfg.painter === 'blocks' || (cfg.painter === 'auto' && !env.kitty)
298 for (const item of found) {
299 const kind = kindOf(item.path)
300 const pages = kind === 'pdf' ? cfg.pdfPages : 1
301 for (let page = 1; kind !== undefined && page <= pages; page++) {
302 const t = await thumbnail($, item.path, kind, page)
303 if ('error' in t) break
304 if (isBlocks && cfg.where[item.source] === 'inline') {
305 const cells = fitCells(t.size, cfg.maxRows, pageWidth(inlineColumns(lastColumns), pages))
306 await blockCells($, t.png, cells.columns, cells.rows, mosaicFor(cfg.detail, env))
307 }
308 }
309 }
310}
311
312// The draft band: Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.png and puts an
313// `[Image #n]` tag in the prompt; pasting raises no prompt event, so the draft is polled. After claude-image-view (MIT).
314async function findPasteDir($: $): Promise<string | undefined> {
315 const sessionId = await $.session.id()
316 if (pasteDir?.sessionId === sessionId) return pasteDir.dir
317 const root = (await $.env.get('CLAUDE_CODE_TMPDIR')) ?? `/tmp/claude-${(await $.process.run(['id', '-u'])).stdout.trim()}`
318 for (const entry of await $.fs.list(root).catch(() => [])) {
319 const dir = `${root}/${entry.name}/${sessionId}/images`
320 if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
321 pasteDir = { sessionId, dir }
322 return dir
323 }
324 }
325 return undefined
326}
327
328async function checkDraft($: $) {
329 if (isChecking) return
330 isChecking = true
331 try {
332 const numbers = imageNumbers((await $.prompt.read()).text)
333 const before = await read($, draft)
334 if (numbers.join() === before.map(d => d.n).join() && before.every(d => d.path !== null)) return
335 const dir = numbers.length > 0 ? await findPasteDir($) : undefined
336 const next: LensDraftImage[] = []
337 for (const n of numbers) {
338 const path = dir === undefined ? null : `${dir}/${n}.png`
339 next.push({ n, path: path !== null && (await $.fs.exists(path)) ? path : null })
340 }
341 if (JSON.stringify(next) !== JSON.stringify(before)) await update($, draft, () => next)
342 } finally {
343 isChecking = false
344 }
345}
346
347function startPolling($: $) {
348 if (isPolling) return
349 isPolling = true
350 $.clock.every(POLL_MS, () => void checkDraft($).catch(() => {}))
351}
352
353/**
354 * At a turn's end: this turn's inline previews that no row drew go to the band above the prompt. A mod outside lens in
355 * the chain (claude-skins draws tool rows and prompts as its own cards) can answer a row without passing it on, and
356 * then lens's hook for that row never runs; the band is a site such mods hand on.
357 */
358async function settle($: $, cfg: Config) {
359 const ids = new Set(turnIds)
360 const list = await read($, gallery)
361 const missing = list.filter(item => ids.has(item.id) && !drawn.has(drawnKey(item.path, item.toolUseId ?? item.row)))
362 .filter(item => cfg.where[item.source] === 'inline' || (item.source === 'pasted' && cfg.where.pasted === 'above'))
363 await update($, unplaced, () => missing.map(item => item.id))
364}
365
366async function status($: $, cfg: Config): Promise<string> {
367 const env = await hostOf($)
368 const blocks = `colored blocks, ${mosaicFor(cfg.detail, env)}${cfg.detail === 'auto' ? ' (auto)' : ''}`
369 const painter = cfg.painter === 'auto' ? `auto (${env.kitty ? 'pictures' : blocks})` : cfg.painter === 'blocks' ? blocks : cfg.painter
370 const off = (await read($, muted)) ? ' (off for now: /lens on)' : ''
371 return [
372 `drawing ${painter}${off}; images ${cfg.kinds.image ? 'on' : 'off'}, svg ${cfg.kinds.svg ? 'on' : 'off'}, pdf ${cfg.kinds.pdf ? `on (${cfg.pdfPages} page${cfg.pdfPages > 1 ? 's' : ''})` : 'off'}`,
373 `placement: pasted ${cfg.where.pasted}, read ${cfg.where.read}, written ${cfg.where.written}, links ${cfg.where.links}, tool images ${cfg.where.toolImages}; up to ${cfg.maxRows} rows`,
374 `converters: ${[...env.tools].join(', ') || 'none found'}; cache ${env.cache}`,
375 `gallery: ${(await read($, gallery)).length} previews this session. Change these in /config (lens rows).`,
376 ].join('\n')
377}
378
379export const register: Register = (on, options) => {
380 const cfg = configOf(options as Record<string, unknown>)
381
382 on('session.start', async ($, e, next) => {
383 void hostOf($)
384 const stored = await $.store.get('muted')
385 if (stored === true) await update($, muted, () => true)
386 if (cfg.where.pasted === 'above') startPolling($)
387 await $.command.register({ name: 'lens', description: 'Image, SVG and PDF previews: the gallery, on/off, status', argumentHint: '[gallery|on|off|status|clear]' })
388 return next(e)
389 })
390
391 on('command.run', { command: 'lens' }, async ($, e) => {
392 const arg = e.args.trim().toLowerCase()
393 if (arg === 'on' || arg === 'off') {
394 await update($, muted, () => arg === 'off')
395 await $.store.set('muted', arg === 'off')
396 return { text: `previews ${arg}` }
397 }
398 if (arg === 'status') return { text: await status($, cfg) }
399 if (arg === 'clear') {
400 await update($, gallery, () => [])
401 return { text: 'gallery cleared' }
402 }
403 if (arg !== '' && arg !== 'gallery') return { text: 'usage: /lens [gallery|on|off|status|clear]' }
404 const opened = await $.ui.open({ id: 'lens', title: 'lens gallery' })
405 return { text: opened.isPlaced ? 'gallery opened' : 'gallery: widen the terminal to see it' }
406 })
407
408 on('prompt.submit', async ($, e, next) => {
409 turnIds = []
410 if ((await read($, unplaced)).length > 0) await update($, unplaced, () => [])
411 return next(e)
412 }).catch(($, e, next) => next(e))
413
414 on('turn.complete', async ($, e, next) => {
415 const done = await next(e)
416 $.clock.after(SETTLE_MS, () => void settle($, cfg).catch(() => {}))
417 return done
418 })
419
420 on('session.append', async ($, e, next) => {
421 const stored = await next(e)
422 if (stored.uuid !== undefined) await collect($, cfg, e, stored.uuid).catch(() => {})
423 return stored
424 }).catch(($, e, next) => next(e))
425
426 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
427 if (e.props.isExpanded || (await read($, muted))) return next(e)
428 const done = new Set(e.props.calls.filter(c => !c.isRunning && !c.isErrored).map(c => c.tool_use_id))
429 return under($, e, next, cfg, await itemsFor($, cfg, item => item.toolUseId !== undefined && done.has(item.toolUseId)))
430 })
431
432 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
433 // A Read is drawn in a group; unfolded (verbose, ctrl+o) its rows are ToolUse with the output inline.
434 if (!READS.has(e.props.tool) || e.props.isRunning || e.props.isErrored || (await read($, muted))) return next(e)
435 return under($, e, next, cfg, await itemsFor($, cfg, item => item.toolUseId === e.props.tool_use_id))
436 })
437
438 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
439 if (READS.has(e.props.tool) || e.props.isErrored || (await read($, muted))) return next(e)
440 return under($, e, next, cfg, await itemsFor($, cfg, item => item.toolUseId === e.props.tool_use_id))
441 })
442
443 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
444 if (await read($, muted)) return next(e)
445 const row = rowKey(e.requestId)
446 return under($, e, next, cfg, await itemsFor($, cfg, item => item.row === row))
447 })
448
449 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
450 if (cfg.where.links !== 'inline' || (await read($, muted))) return next(e)
451 const env = await hostOf($)
452 const items = imageLinks(e.props.text, env.cwd).map(link => ({ path: link.path, label: linkLabel(link), key: hash(link.path), drawnAs: drawnKey(link.path) }))
453 return under($, e, next, cfg, items)
454 })
455
456 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
457 if (e.props.hasSurvey || (await read($, muted))) return next(e)
458 if (cfg.where.pasted === 'above') startPolling($)
459 const drafts = cfg.where.pasted === 'above' ? (await read($, draft)).filter(d => d.path !== null) : []
460 const ids = new Set(await read($, unplaced))
461 const missed = ids.size === 0 ? [] : (await read($, gallery)).filter(item => ids.has(item.id))
462 if (drafts.length === 0 && missed.length === 0) return next(e)
463 const below = await next(e)
464 const { Box, Text } = $.ui.resolve(e) as never as { Box: any; Text: any }
465 const rows = Math.max(1, Math.min(cfg.maxRows, 6, e.props.maxRows - 4))
466 const tiles = async (items: readonly Preview[]) =>
467 previews($, e, cfg, items, Math.max(4, Math.floor(e.props.bodyColumns / items.length) - 2), rows)
468 const draftNodes = drafts.length === 0 ? [] : await tiles(drafts.map(d => ({ path: d.path!, label: `Image #${d.n}`, key: `draft-${d.n}` })))
469 const missedNodes = missed.length === 0 ? [] : await tiles(missed.map(item => ({ path: item.path, label: item.label, key: `missed-${hash(item.id)}` })))
470 return (
471 <Box flexDirection="column">
472 {missedNodes.length > 0 ? <Text dimColor>lens: previews from this turn (another mod drew their rows)</Text> : null}
473 {missedNodes.length > 0 ? <Box flexDirection="row" columnGap={2}>{missedNodes}</Box> : null}
474 {draftNodes.length > 0 ? <Box flexDirection="row" columnGap={2}>{draftNodes}</Box> : null}
475 {below}
476 </Box>
477 )
478 })
479
480 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
481 if (e.requestId !== 'lens') return next(e)
482 const { Box, Text } = $.ui.resolve(e) as never as { Box: any; Text: any }
483 const list = [...(await read($, gallery))].filter(item => cfg.where[item.source] !== 'off').reverse().slice(0, GALLERY_SHOWN)
484 if (list.length === 0) return <Text dimColor>No previews yet this session. Paste an image, or have Claude read or write one.</Text>
485 const items = list.map(item => ({ path: item.path, label: `${item.label} (${item.source === 'toolImages' ? 'tool image' : item.source})`, key: hash(item.id) }))
486 const nodes = await previews($, e, cfg, items, e.props.bodyColumns - 2, Math.min(cfg.maxRows, 8))
487 return <Box flexDirection="column" rowGap={1}>{nodes}</Box>
488 })
489}
490hooks/lib.ts 384 lines1// Pure helpers: no `$` here (the engine follows `$` only within register.tsx).
2
3export type Kind = 'image' | 'svg' | 'pdf'
4export type Where = 'above' | 'inline' | 'pane' | 'off'
5export type Painter = 'auto' | 'kitty' | 'blocks' | 'text'
6/** How finely the colored blocks divide a cell: 2x3 sextants, 2x2 quadrants, or 1x2 half blocks. */
7export type Detail = 'auto' | 'sextant' | 'quadrant' | 'halfblock'
8export type Mosaic = Exclude<Detail, 'auto'>
9export type Source = 'pasted' | 'read' | 'written' | 'links' | 'toolImages'
10
11export type Config = {
12 kinds: Record<Kind, boolean>
13 where: Record<Source, Where>
14 painter: Painter
15 detail: Detail
16 maxRows: number
17 pdfPages: number
18}
19
20const pick = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
21 allowed.includes(value as T) ? (value as T) : fallback
22const clamp = (value: unknown, lo: number, hi: number, fallback: number) =>
23 typeof value === 'number' && Number.isFinite(value) ? Math.min(hi, Math.max(lo, Math.round(value))) : fallback
24
25/** The plugin's options (userConfig) as a typed config, out-of-range values clamped. */
26export function configOf(o: Record<string, unknown>): Config {
27 const tool = ['inline', 'pane', 'off'] as const
28 return {
29 kinds: { image: o.images !== false, svg: o.svg !== false, pdf: o.pdf !== false },
30 where: {
31 pasted: pick(o.pasted, ['above', 'inline', 'pane', 'off'], 'above'),
32 read: pick(o.read, tool, 'inline'),
33 written: pick(o.written, tool, 'inline'),
34 links: pick(o.links, tool, 'inline'),
35 toolImages: pick(o.toolImages, tool, 'inline'),
36 },
37 painter: pick(o.painter, ['auto', 'kitty', 'blocks', 'text'], 'auto'),
38 detail: pick(o.detail, ['auto', 'sextant', 'quadrant', 'halfblock'], 'auto'),
39 maxRows: clamp(o.maxRows, 2, 40, 10),
40 pdfPages: clamp(o.pdfPages, 1, 8, 1),
41 }
42}
43
44const EXT: Record<string, Kind> = {
45 png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', bmp: 'image', tif: 'image', tiff: 'image', heic: 'image',
46 svg: 'svg', pdf: 'pdf',
47}
48
49/** The kind of file a path names, by its extension; undefined when lens does not preview it. */
50export function kindOf(path: string): Kind | undefined {
51 const m = /\.([A-Za-z0-9]+)$/.exec(path)
52 return m ? EXT[m[1]!.toLowerCase()] : undefined
53}
54
55export const MEDIA_EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
56
57const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
58export { base as baseName }
59
60/** Absolute path for `path` under `cwd`; `file://` URLs unwrapped; undefined for a web URL. */
61export function resolvePath(path: string, cwd: string): string | undefined {
62 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path) && !path.startsWith('file://')) return undefined
63 let p = path.startsWith('file://') ? decodeURIComponent(path.slice(7)) : path
64 if (p.startsWith('<') && p.endsWith('>')) p = p.slice(1, -1)
65 if (p.startsWith('/')) return p
66 const parts = `${cwd}/${p}`.split('/')
67 const out: string[] = []
68 for (const part of parts) {
69 if (part === '..') out.pop()
70 else if (part !== '.' && part !== '') out.push(part)
71 }
72 return '/' + out.join('/')
73}
74
75/** The markdown images (``) in a reply with a local, previewable path: resolved, deduplicated, in order. */
76export function imageLinks(text: string, cwd: string): { path: string; alt: string }[] {
77 const seen = new Map<string, string>()
78 for (const m of text.matchAll(/!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(?:\s+"[^"]*")?\)/g)) {
79 const p = resolvePath(m[2]!, cwd)
80 if (p !== undefined && kindOf(p) !== undefined && !seen.has(p)) seen.set(p, m[1]!.trim())
81 }
82 return [...seen].map(([path, alt]) => ({ path, alt }))
83}
84
85/** What a link's preview is labelled with: the link's own text and the file, as Claude Code draws the link (`alt (file)`). */
86export const linkLabel = (link: { path: string; alt: string }) =>
87 link.alt === '' ? base(link.path) : `${link.alt} (${base(link.path)})`
88
89/** Paths in a shell command that name a previewable file, quoted or bare (a heuristic: the caller checks they were just written). */
90export function commandPaths(command: string, cwd: string): string[] {
91 const ext = '\\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|svg|pdf)'
92 const re = new RegExp(`"([^"]+${ext})"|'([^']+${ext})'|(?:^|[\\s=(])([^\\s'"\`;|&<>()=]+${ext})(?=$|[\\s;|&)])`, 'gi')
93 const seen = new Set<string>()
94 for (const m of command.matchAll(re)) {
95 const p = resolvePath(m[1] ?? m[2] ?? m[3]!, cwd)
96 if (p !== undefined) seen.add(p)
97 }
98 return [...seen]
99}
100
101/** The transcript row a pasted prompt is drawn under: its append uuid with the last group zeroed. */
102export const rowKey = (uuid: string) => uuid.split('-').slice(0, 4).join('-')
103
104/** FNV-1a, hex: a cache file name from a path and what makes its content new. */
105export function hash(text: string): string {
106 let h = 0x811c9dc5
107 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
108 return (h >>> 0).toString(16).padStart(8, '0')
109}
110
111export type Size = { width: number; height: number }
112
113/** Width and height from a PNG's IHDR (base64 of at least its first 24 bytes), or null. */
114export function pngSize(base64: string): Size | null {
115 const head = Uint8Array.fromBase64(base64.slice(0, 32))
116 const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
117 if (head.length < 24 || sig.some((b, i) => head[i] !== b)) return null
118 const v = new DataView(head.buffer, head.byteOffset, head.byteLength)
119 const width = v.getUint32(16), height = v.getUint32(20)
120 return width > 0 && height > 0 ? { width, height } : null
121}
122
123export type Cells = { columns: number; rows: number }
124
125/** A box of cells that keeps the picture's shape (a cell is about twice as tall as wide), within the limits. */
126export function fitCells(size: Size, maxRows: number, maxColumns: number): Cells {
127 let rows = maxRows
128 let columns = Math.round((rows * 2 * size.width) / size.height)
129 if (columns > maxColumns) {
130 columns = maxColumns
131 rows = Math.max(1, Math.round((columns * size.height) / (2 * size.width)))
132 }
133 return { columns: Math.max(2, Math.min(columns, 255)), rows: Math.max(1, Math.min(rows, 255)) }
134}
135
136export type Pixels = { width: number; height: number; rgba: Uint8Array }
137
138/** An uncompressed BMP (24 or 32 bit, BI_RGB or BITFIELDS, either row order), what `sips -s format bmp` writes. */
139export function parseBmp(bytes: Uint8Array): Pixels {
140 const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
141 if (v.getUint16(0, true) !== 0x4d42) throw new Error('not a BMP')
142 const off = v.getUint32(10, true), hdr = v.getUint32(14, true)
143 const width = v.getInt32(18, true), rawH = v.getInt32(22, true), bpp = v.getUint16(28, true), comp = v.getUint32(30, true)
144 const height = Math.abs(rawH), topDown = rawH < 0
145 const masks = comp === 3 && hdr >= 52
146 ? [v.getUint32(54, true), v.getUint32(58, true), v.getUint32(62, true), hdr >= 56 ? v.getUint32(66, true) : 0]
147 : [0xff0000, 0xff00, 0xff, 0]
148 const shift = (m: number) => { let s = 0; while (m && !(m & 1)) { m >>>= 1; s++ } return s }
149 const sh = masks.map(shift)
150 const stride = Math.ceil((width * bpp) / 32) * 4
151 const rgba = new Uint8Array(width * height * 4)
152 if (bpp !== 32 && bpp !== 24) throw new Error(`BMP of ${bpp} bits`)
153 for (let y = 0; y < height; y++) {
154 const row = off + (topDown ? y : height - 1 - y) * stride
155 for (let x = 0; x < width; x++) {
156 const o = (y * width + x) * 4
157 if (bpp === 32) {
158 const p = v.getUint32(row + x * 4, true)
159 rgba[o] = (p & masks[0]!) >>> sh[0]!
160 rgba[o + 1] = (p & masks[1]!) >>> sh[1]!
161 rgba[o + 2] = (p & masks[2]!) >>> sh[2]!
162 rgba[o + 3] = masks[3] ? (p & masks[3]) >>> sh[3]! : 255
163 } else {
164 const q = row + x * 3
165 rgba[o] = bytes[q + 2]!; rgba[o + 1] = bytes[q + 1]!; rgba[o + 2] = bytes[q]!; rgba[o + 3] = 255
166 }
167 }
168 }
169 return { width, height, rgba }
170}
171
172/** A binary PPM (P6, maxval 255), what ImageMagick writes for `ppm:` and pdftoppm by default. */
173export function parsePpm(bytes: Uint8Array): Pixels {
174 let i = 0
175 const token = () => {
176 while (true) {
177 while (i < bytes.length && /\s/.test(String.fromCharCode(bytes[i]!))) i++
178 if (bytes[i] !== 0x23) break
179 while (i < bytes.length && bytes[i] !== 0x0a) i++
180 }
181 const start = i
182 while (i < bytes.length && !/\s/.test(String.fromCharCode(bytes[i]!))) i++
183 return String.fromCharCode(...bytes.subarray(start, i))
184 }
185 if (token() !== 'P6') throw new Error('not a binary PPM')
186 const width = Number(token()), height = Number(token()), max = Number(token())
187 if (max !== 255) throw new Error(`PPM maxval ${max}`)
188 i++
189 const rgba = new Uint8Array(width * height * 4)
190 for (let p = 0; p < width * height; p++) {
191 rgba[p * 4] = bytes[i + p * 3]!; rgba[p * 4 + 1] = bytes[i + p * 3 + 1]!; rgba[p * 4 + 2] = bytes[i + p * 3 + 2]!; rgba[p * 4 + 3] = 255
192 }
193 return { width, height, rgba }
194}
195
196const DEFAULT_COLOR = 0x01000000
197
198
199/**
200 * The mosaic `auto` draws with: sextants in CSM (its xterm.js WebGL renderer draws U+1FB00-1FB3B itself, as solid
201 * blocks), quadrants elsewhere (Menlo and most monospace fonts have U+2596-259F; no font macOS ships has sextants).
202 */
203export const mosaicFor = (detail: Detail, env: { csm: boolean }): Mosaic =>
204 detail !== 'auto' ? detail : env.csm ? 'sextant' : 'quadrant'
205
206/** Pixel samples per cell, across and down, for each mosaic. */
207export const SAMPLES: Record<Mosaic, { x: number; y: number }> = { halfblock: { x: 1, y: 2 }, quadrant: { x: 2, y: 2 }, sextant: { x: 2, y: 3 } }
208
209/** Quadrant glyphs by mask, bit 0 upper left, 1 upper right, 2 lower left, 3 lower right (Block Elements, U+2580-259F). */
210export const QUADRANTS = [
211 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
212 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
213]
214
215/**
216 * The sextant glyph for a mask, bit n the n+1th of the six sub-cells read row by row (upper left, upper right, middle
217 * left, ...). U+1FB00-1FB3B (Symbols for Legacy Computing) skip the three that Block Elements already have: the empty
218 * cell, the left and right halves and the full block.
219 */
220export function sextantGlyph(mask: number): number {
221 if (mask === 0) return 0x20
222 if (mask === 63) return 0x2588
223 if (mask === 21) return 0x258c
224 if (mask === 42) return 0x2590
225 return 0x1fb00 + mask - 1 - (mask > 21 ? 1 : 0) - (mask > 42 ? 1 : 0)
226}
227
228export type Cell = { glyph: number; fg: number; bg: number }
229
230/**
231 * The two colors and the split that draw `n` samples best: every split of the samples into a foreground and a background
232 * set is tried, each set taking its mean color, and the one with the least squared error wins (2^(n-1) splits: 2 for half
233 * blocks, 8 for quadrants, 32 for sextants). Samples whose alpha is under 128 count as the terminal's own background; a
234 * set made only of them takes the default color. `rgba` holds the n samples, 4 bytes each.
235 */
236export function fitCell(rgba: ArrayLike<number>, n: number): { mask: number; fg: number; bg: number } {
237 const clear = (i: number) => rgba[i * 4 + 3]! < 128
238 // Clear samples are fitted as a mid grey so they group together, then drawn as the default color.
239 const ch = (i: number, c: number) => (clear(i) ? 128 : rgba[i * 4 + c]!)
240 let best = { mask: 0, error: Infinity, fg: 0, bg: 0 }
241 for (let mask = 0; mask < 1 << (n - 1); mask++) {
242 const sums = [[0, 0, 0, 0], [0, 0, 0, 0]] as [number[], number[]]
243 for (let i = 0; i < n; i++) {
244 const s = sums[(mask >> i) & 1]!
245 s[0]! += ch(i, 0); s[1]! += ch(i, 1); s[2]! += ch(i, 2); s[3]! += 1
246 }
247 const mean = sums.map(s => (s[3] === 0 ? [0, 0, 0] : [s[0]! / s[3]!, s[1]! / s[3]!, s[2]! / s[3]!]))
248 let error = 0
249 for (let i = 0; i < n; i++) {
250 const m = mean[(mask >> i) & 1]!
251 error += (ch(i, 0) - m[0]!) ** 2 + (ch(i, 1) - m[1]!) ** 2 + (ch(i, 2) - m[2]!) ** 2
252 }
253 if (error < best.error) {
254 const color = (set: 0 | 1, m: number[]) => {
255 let allClear = true
256 for (let i = 0; i < n; i++) if (((mask >> i) & 1) === set && !clear(i)) allClear = false
257 return allClear ? DEFAULT_COLOR : (Math.round(m[0]!) << 16) | (Math.round(m[1]!) << 8) | Math.round(m[2]!)
258 }
259 best = { mask, error, fg: color(1, mean[1]!), bg: color(0, mean[0]!) }
260 }
261 }
262 // Masks are tried with the last sample in the background; when every sample is one color, draw it as background.
263 return { mask: best.mask, fg: best.fg, bg: best.bg }
264}
265
266/** Cells for pixels sampled `SAMPLES[mode]` per cell: `(columns * x) x (rows * y)` pixels in, `columns x rows` cells out. */
267export function mosaicCells(p: Pixels, mode: Mosaic): { columns: number; rows: number; cells: Cell[] } {
268 const { x: sx, y: sy } = SAMPLES[mode]
269 const columns = Math.floor(p.width / sx), rows = Math.ceil(p.height / sy)
270 const n = sx * sy
271 const block = new Uint8Array(n * 4)
272 const cells: Cell[] = []
273 for (let r = 0; r < rows; r++) {
274 for (let c = 0; c < columns; c++) {
275 for (let dy = 0; dy < sy; dy++) {
276 for (let dx = 0; dx < sx; dx++) {
277 const y = r * sy + dy, o = (y * p.width + c * sx + dx) * 4, i = (dy * sx + dx) * 4
278 if (y < p.height) block.set(p.rgba.subarray(o, o + 4), i)
279 else block.fill(0, i, i + 4) // below the last pixel row: clear
280 }
281 }
282 const { mask, fg, bg } = fitCell(block, n)
283 const glyph = mode === 'sextant' ? sextantGlyph(mask) : mode === 'quadrant' ? QUADRANTS[mask]! : mask === 1 ? 0x2580 : mask === 2 ? 0x2584 : 0x20
284 cells.push({ glyph, fg, bg })
285 }
286 }
287 return { columns, rows, cells }
288}
289
290/** A `Raster`'s packed cells (u32 triplets, base64); every glyph must be in the BMP, so not sextants. */
291export function packCells(m: { columns: number; rows: number; cells: Cell[] }): { cells: string; columns: number; rows: number } {
292 const words = new Uint32Array(m.cells.length * 3)
293 m.cells.forEach((c, i) => { words[i * 3] = c.glyph; words[i * 3 + 1] = c.fg; words[i * 3 + 2] = c.bg })
294 return { cells: new Uint8Array(words.buffer).toBase64(), columns: m.columns, rows: m.rows }
295}
296
297export type Run = { text: string; fg?: string; bg?: string }
298
299/** Rows of text runs, adjacent cells of the same colors joined: how sextants are drawn (`Raster` refuses beyond-BMP glyphs). */
300export function textRows(m: { columns: number; rows: number; cells: Cell[] }): Run[][] {
301 const hex = (c: number) => (c === DEFAULT_COLOR ? undefined : '#' + c.toString(16).padStart(6, '0'))
302 const out: Run[][] = []
303 for (let r = 0; r < m.rows; r++) {
304 const row: Run[] = []
305 for (let c = 0; c < m.columns; c++) {
306 const cell = m.cells[r * m.columns + c]!
307 const fg = cell.glyph === 0x20 ? undefined : hex(cell.fg), bg = hex(cell.bg)
308 const last = row[row.length - 1]
309 if (last && last.fg === fg && last.bg === bg) last.text += String.fromCodePoint(cell.glyph)
310 else row.push({ text: String.fromCodePoint(cell.glyph), ...(fg ? { fg } : {}), ...(bg ? { bg } : {}) })
311 }
312 out.push(row)
313 }
314 return out
315}
316
317/** Distinct (foreground, background) pairs, the unit `Raster`'s palette counts (1024 per frame). */
318export const colorPairs = (cells: readonly Cell[]) => new Set(cells.map(c => `${c.fg}/${c.bg}`)).size
319
320/** The tools found on this machine, by the name `command -v` knows them. */
321export type Tools = ReadonlySet<string>
322
323export const TOOL_NAMES = ['sips', 'osascript', 'pdftoppm', 'mutool', 'rsvg-convert', 'magick', 'convert']
324
325/**
326 * RENDERERS: how each kind becomes a PNG of at most `px` on its longest side, as argv steps run in order.
327 * A new kind is one entry here, one extension in EXT and one userConfig key. Undefined: no tool for it here.
328 */
329export const RENDERERS: Record<Kind, (input: string, out: string, px: number, page: number, tools: Tools, root: string) => string[][] | undefined> = {
330 image: (input, out, px, _page, tools) => {
331 if (tools.has('sips')) return [['sips', '-s', 'format', 'png', '-Z', String(px), input, '--out', out]]
332 const im = tools.has('magick') ? 'magick' : tools.has('convert') ? 'convert' : undefined
333 return im ? [[im, `${input}[0]`, '-thumbnail', `${px}x${px}`, `png:${out}`]] : undefined
334 },
335 svg: (input, out, px, _page, tools) => {
336 if (tools.has('sips')) return [['sips', '-s', 'format', 'png', '-Z', String(px), input, '--out', out]]
337 if (tools.has('rsvg-convert')) return [['rsvg-convert', '--width', String(px), '--keep-aspect-ratio', '-o', out, input]]
338 const im = tools.has('magick') ? 'magick' : tools.has('convert') ? 'convert' : undefined
339 return im ? [[im, input, '-thumbnail', `${px}x${px}`, `png:${out}`]] : undefined
340 },
341 pdf: (input, out, px, page, tools, root) => {
342 // sips draws page 1 in about 60 ms; PDFKit through JXA draws any page in 200-900 ms.
343 if (page === 1 && tools.has('sips')) return [['sips', '-s', 'format', 'png', '-Z', String(px), input, '--out', out]]
344 if (tools.has('osascript')) return [['osascript', '-l', 'JavaScript', `${root}/bin/pdfpage.js`, input, String(page), String(px), out]]
345 if (tools.has('pdftoppm')) {
346 return [['pdftoppm', '-png', '-f', String(page), '-l', String(page), '-scale-to', String(px), '-singlefile', input, out.replace(/\.png$/, '')]]
347 }
348 if (tools.has('mutool')) return [['mutool', 'draw', '-q', '-o', out, '-w', String(px), input, String(page)]]
349 const im = tools.has('magick') ? 'magick' : tools.has('convert') ? 'convert' : undefined
350 return im ? [[im, '-density', '96', `${input}[${page - 1}]`, '-thumbnail', `${px}x${px}`, `png:${out}`]] : undefined
351 },
352}
353
354/** How a PNG thumbnail becomes `width` x `height` raw pixels for the colored-block painter. */
355export function pixelsPlan(png: string, out: string, width: number, height: number, tools: Tools):
356 { steps: string[][]; format: 'bmp' | 'ppm' } | undefined {
357 if (tools.has('sips')) return { steps: [['sips', '-s', 'format', 'bmp', '-z', String(height), String(width), png, '--out', `${out}.bmp`]], format: 'bmp' }
358 const im = tools.has('magick') ? 'magick' : tools.has('convert') ? 'convert' : undefined
359 return im ? { steps: [[im, png, '-resize', `${width}x${height}!`, `ppm:${out}.ppm`]], format: 'ppm' } : undefined
360}
361
362/** Whether this terminal paints the engine's Image (kitty graphics with Unicode placeholders), from its environment. */
363export function paintsImages(env: Record<string, string | undefined>): boolean {
364 if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES === '1') return true
365 if (env.TMUX || env.STY) return false
366 return env.TERM === 'xterm-kitty' || env.TERM === 'xterm-ghostty' || env.TERM_PROGRAM === 'ghostty' ||
367 env.TERM_PROGRAM === 'kitty' || env.KITTY_WINDOW_ID !== undefined || env.GHOSTTY_RESOURCES_DIR !== undefined
368}
369
370/** The image numbers a draft references (`[Image #n]`), deduplicated, in order. From claude-image-view (MIT). */
371export function imageNumbers(draft: string): number[] {
372 const seen = new Set<number>()
373 for (const m of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(m[1]))
374 return [...seen]
375}
376
377/** Labels for a prompt's pasted images, in order: Claude Code's own `Image #n` from the prompt's `[Image #n]` tags
378 * (the stored row keeps the ids beside the blocks, `imagePasteIds`, but the hook's input does not carry them), or
379 * `pasted image n` when the tags do not match the images one for one. */
380export function pastedLabels(text: string, count: number): string[] {
381 const nums = imageNumbers(text)
382 return Array.from({ length: count }, (_, i) => (nums.length === count ? `Image #${nums[i]}` : `pasted image ${i + 1}`))
383}
384types/index.d.ts 26 lines1/** One preview lens has seen this session: a file on disk, and where it came from. */
2export type LensItem = {
3 /** Unique within the session. */
4 id: string
5 source: 'pasted' | 'read' | 'written' | 'links' | 'toolImages'
6 /** Absolute path of the file shown (a pasted or returned image is first written to the cache). */
7 path: string
8 /** What the person reads beside it: the file's name, or `pasted image 1`. */
9 label: string
10 /** The tool call it belongs to, for previews drawn under a tool row. */
11 toolUseId?: string
12 /** The prompt row it belongs to (the append uuid less its last group), for pasted images. */
13 row?: string
14 /** When lens saw it, epoch ms. */
15 at: number
16}
17
18/** An image the draft references as `[Image #n]`, and its file in Claude Code's paste cache when found. */
19export type LensDraftImage = { n: number; path: string | null }
20
21declare module 'claude-code' {
22 interface PluginState {
23 lens: { gallery: LensItem[]; draft: LensDraftImage[]; muted: boolean; unplaced: string[] }
24 }
25}
26