SLOPSHOPPER

lens

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

newpanebandrowscommandprompt
v0.2.2MITupdated 2026-10-12minhdevtree/claude-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lens
│ ┃ lens gallery ✕ › fix the failing auth test and add an audit log call │ ┃ No previews yet this session. Paste an │ ┃ image, or have Claude read or write one. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lens │ ⎿ lens: gallery opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lens gallery
No previews yet this session. Paste an image, or have Claude read or write one.
README

claude-lens

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.

lens in Claude Code: a dashboard screenshot Claude read, both pages of a PDF, a bar chart linked in a reply and an SVG logo Claude wrote, each drawn as the real picture under its row

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

Install

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.

What you see, where

Where Claude Code runsWhat lens draws
kitty, Ghostty, CSM (Claude Session Manager) 2.127.0 or later on macOS and LinuxThe picture itself (kitty graphics protocol)
CSM on Windows, CSM before 2.127.0Colored 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.jsColored blocks, each cell split 2x2 (quadrants)
Claude desktop app, VS Code extension, mobileSVG files only, drawn by the app; other previews are left out

the same chart, PDF page and logo at the same size: half blocks, quadrants, sextants

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.

What gets previewed

SourceExampleShown
Pasted images[Image #1] in your promptAbove the prompt while you write, then under your message, labelled Image #1 as Claude Code names it
Files Claude readsRead of a PNG, SVG or PDFUnder the "Read 1 file" row
Files Claude writesWrite or Edit of an SVG, a Bash command that makes out.pngUnder the tool's result
Image links in repliesrevenue by monthUnder the reply, labelled the way Claude Code draws the link: revenue by month (chart.png)
Images tools returnAn MCP or browser tool's screenshotUnder 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.

With other mods (claude-skins and similar)

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
}

Settings

Every setting is a row in /config (type lens to filter):

the lens rows in /config

RowValuesDefault
Images, SVG, PDFon / off per file typeon
Pasted imagesabove (over the prompt while you write, then under your message), inline, pane (gallery only), offabove
Files Claude reads, Files Claude writes, Image links in replies, Images tools returninline, pane (gallery only), offinline
Drawingauto (pictures in kitty, Ghostty and CSM 2.127.0+, blocks elsewhere), kitty, blocks, text (file names only)auto
Block detailauto (sextants in CSM, quadrants elsewhere), sextant (2x3 per cell), quadrant (2x2), halfblock (1x2, what 0.1 drew)auto
Thumbnail rows2 to 4010
PDF pages1 to 81

Quick switches for the session:

CommandDoes
/lensOpens the gallery
/lens off, /lens onHides or shows every preview (remembered)
/lens statusShows the drawing mode, the settings and the converters found
/lens clearEmpties the gallery

Requirements

  • Claude Code 2.1.294 or later (checked on 2.1.294 and 2.1.296).
  • macOS: nothing to install. lens uses sips and PDFKit, which ship with macOS.
  • Linux: lens uses what it finds.
  • PDF: pdftoppm (poppler-utils) or mutool (mupdf-tools)
  • SVG: rsvg-convert (librsvg2-bin) or ImageMagick
  • PNG, JPEG, GIF, WebP, and the colored blocks: ImageMagick (magick or convert)

On Debian or Ubuntu: sudo apt install poppler-utils librsvg2-bin imagemagick.

  • Windows: not supported yet (lens runs its converters through /bin/sh).

Privacy

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.

Troubleshooting

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.

Limits

  • At most 255 pictures can be on screen at once in kitty and Ghostty (the terminal's image ids). Past that, Claude Code draws the file name instead.
  • Half-block and quadrant thumbnails share a palette of 1024 color pairs per frame, so many at once lose some color. Sextants are drawn as colored text and keep theirs.

How it works

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.

Development

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

Credits

  • claude-image-view by Jarrod Watts (MIT): the band of pasted images above the prompt, and how to find Claude Code's paste cache, come from it.
  • The Claude Code mods documentation and the API types Claude Code ships.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 490 lines
1import { 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}
490
hooks/lib.ts 384 lines
1// 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 (`![alt](path)`) 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}
384
types/index.d.ts 26 lines
1/** 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