Diagram and image viewer pane: Claude renders Mermaid, Graphviz, TikZ, Typst, PDF, SVG and images, shows them beside the transcript, and looks at every render.

A Claude Code mod that shows diagrams and images in a side pane, and makes Claude look at every render.
show with Mermaid or Graphviz source, or a file (.tex .typ .pdf .svg .png .jpg …). iris renders it to PNG, opens the pane, and tells Claude to read the image back before claiming anything about it.id adds versions (v2, v3 …); watched files re-render when they change on disk.o open the vector original, s source, z/x zoom, h j k l pan, 0 fit, p/n versions, b/f PDF pages./show [n | path] opens the pane, selects a figure, or shows a file.Needs: Claude Code ≥ 2.1.287; ImageMagick (magick), Graphviz (dot), poppler (pdftoppm, pdfinfo); optionally mmdc (Mermaid CLI), pdflatex, typst.
hooks/register.tsx 560 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ImageSource, Register } from 'claude-code'
3
4import type { IrisFigure, IrisImages, IrisVersion, IrisZoom } from '../types'
5import { basename, convert, crop, dimsOf, dirname, extOf, fitCells, rasterize, renderPdfPage, TEXT_EXTS } from './render'
6import type { RasterCells } from './raster'
7
8const PANE = 'iris'
9const TOOL = 'mcp__iris__show'
10
11const figures = atom({ plugin: 'iris', key: 'figures' } as const, [])
12const sel = atom({ plugin: 'iris', key: 'sel' } as const, null)
13const view = atom({ plugin: 'iris', key: 'view' } as const, 'preview')
14const FIT: IrisZoom = { z: 1, cx: 0.5, cy: 0.5 }
15const zoom = atom({ plugin: 'iris', key: 'zoom' } as const, FIT)
16const images = atom({ plugin: 'iris', key: 'images' } as const, 'unknown' as IrisImages)
17
18const SHOW_DESCRIPTION = [
19 'Show a diagram or image to the user in the iris side pane, and get back a PNG of what they see.',
20 'Use it whenever a picture helps the user follow along: architecture or data flow (mermaid), graphs (dot),',
21 'figures you compiled (.tex, .typ, .pdf, .svg), plots and sample data (.png, .jpg).',
22 'Reuse an `id` to add a new version of the same figure while iterating on it.',
23 'The pane is a narrow sidebar: prefer top-down layouts (flowchart TB, rankdir=TB) over long left-to-right ones.',
24 'After every call, Read the returned PNG and say what is actually visible (layout, labels, anything off)',
25 'before claiming anything about the figure.',
26].join(' ')
27
28const SHOW_SCHEMA = {
29 type: 'object',
30 properties: {
31 title: { type: 'string', description: 'Short title for the pane header.' },
32 caption: { type: 'string', description: 'One line saying what to look at.' },
33 mermaid: { type: 'string', description: 'Mermaid source to render.' },
34 dot: { type: 'string', description: 'Graphviz dot source to render.' },
35 path: {
36 type: 'string',
37 description: 'File to show: .mmd .dot .gv .tex .typ .pdf .svg .png .jpg .jpeg .gif .webp .bmp. Watched for changes.',
38 },
39 id: { type: 'string', description: 'Stable figure id. Reusing it adds a version (v2, v3, ...).' },
40 page: { type: 'integer', minimum: 1, description: 'PDF page to show (default 1).' },
41 page_titles: {
42 type: 'array',
43 items: { type: 'string' },
44 description: 'Title for each PDF page, in order; the header shows only the current page\'s title.',
45 },
46 save_to: { type: 'string', description: 'Also copy the rendered PNG to this path.' },
47 },
48 additionalProperties: false,
49}
50
51type ShowArgs = {
52 title?: string
53 caption?: string
54 mermaid?: string
55 dot?: string
56 path?: string
57 id?: string
58 page?: number
59 page_titles?: string[]
60 save_to?: string
61}
62
63type Shown = { fig: IrisFigure; version: IrisVersion; png: string }
64
65// Module variables are rebuilt on every (re)load: session.start fills them again.
66let cwd = '.'
67let base = '/tmp/iris'
68let watch: { cancel: () => void } | undefined
69let isPolling = false
70// Debug trace of pane redraws while probing, written to <base>/render-log.jsonl (last 40 entries).
71const renderLog: string[] = []
72// /show probe: draw the image as a file source and as PNG bytes, to compare how the terminal scales each.
73let isProbe = false
74// When the renderer was last asked how an Image came out, and the viewport it was asked for.
75let probedAt = 0
76let probedViewport = ''
77const failedMtime = new Map<number, number>()
78const rasters = new Map<string, RasterCells | string>()
79const dims = new Map<string, { w: number; h: number }>()
80
81const abs = (path: string) => (path.startsWith('/') ? path : `${cwd}/${path}`.replace(/\/\.\//g, '/'))
82const figDir = (n: number) => `${base}/${n}`
83const pngOf = (version: IrisVersion, page: number) => version.png[String(page)]
84
85async function sh($: EngineInterface, argv: string[]) {
86 return $.process.run(argv, { timeoutMs: 30_000 })
87}
88
89/** Opens a file in VS Code (inside its terminal) or the desktop viewer, detached. */
90async function openExternal($: EngineInterface, file: string) {
91 const isVscode = (await $.env.get('TERM_PROGRAM')) === 'vscode'
92 const opener = isVscode ? 'code -r "$1"' : 'xdg-open "$1"'
93 await sh($, ['sh', '-c', `${opener} >/dev/null 2>&1 &`, 'iris', file])
94}
95
96/** The working folder and mmdc's config; recreated whenever missing. */
97async function ensureBase($: EngineInterface) {
98 const config = `${base}/puppeteer.json`
99 if (!(await $.fs.exists(config))) await $.fs.write(config, '{"args":["--no-sandbox"]}\n')
100}
101
102async function show($: EngineInterface, args: ShowArgs, isFromWatch = false): Promise<Shown> {
103 await ensureBase($)
104 const list = await read($, figures)
105 const existing = args.id === undefined ? undefined : list.find(f => f.id === args.id)
106 const n = existing?.n ?? list.length + 1
107 const dir = figDir(n)
108 const v = (existing?.versions.length ?? 0) + 1
109
110 let path: string
111 let isWatched: boolean
112 if (args.mermaid !== undefined || args.dot !== undefined) {
113 path = `${dir}/v${v}.${args.mermaid !== undefined ? 'mmd' : 'dot'}`
114 await $.fs.write(path, args.mermaid ?? args.dot ?? '')
115 isWatched = false
116 } else if (args.path !== undefined) {
117 path = abs(args.path)
118 isWatched = true
119 } else if (existing) {
120 path = existing.path
121 isWatched = existing.isWatched
122 } else {
123 throw new Error('iris needs one of: mermaid, dot, path')
124 }
125
126 const page = args.page ?? existing?.page ?? 1
127 const rendered = await convert($, path, dir, v, page, `${base}/puppeteer.json`)
128 await sh($, ['cp', rendered.png, `${dir}/current.png`])
129 const mtime = isWatched ? (await $.fs.stat(path)).mtimeMs : undefined
130
131 const version: IrisVersion = {
132 v,
133 at: await $.clock.now(),
134 pdf: rendered.pdf,
135 vector: rendered.vector,
136 pages: rendered.pages,
137 png: { [String(rendered.page)]: rendered.png },
138 }
139 const fig: IrisFigure = {
140 n,
141 id: existing?.id ?? args.id ?? `fig${n}`,
142 title: args.title ?? existing?.title ?? basename(path),
143 caption: args.caption ?? existing?.caption,
144 path,
145 isWatched,
146 page: rendered.page,
147 pageTitles: args.page_titles ?? existing?.pageTitles,
148 mtime,
149 versions: [...(existing?.versions ?? []), version],
150 }
151 await update($, figures, all => [...all.filter(f => f.n !== n), fig].sort((a, b) => a.n - b.n))
152 await update($, sel, () => ({ n, v }))
153 await update($, view, () => 'preview')
154 await update($, zoom, () => FIT)
155 failedMtime.delete(n)
156
157 if (args.save_to !== undefined) {
158 const target = abs(args.save_to)
159 await sh($, ['sh', '-c', 'mkdir -p "$(dirname "$2")" && cp "$1" "$2"', 'iris', rendered.png, target])
160 }
161
162 const opened = await $.ui.open({ id: PANE, title: 'iris' })
163 if (!opened.isPlaced) {
164 $.ui.toast(`iris: #${n} ${fig.title} v${v} ready. /show to view`)
165 }
166 // Where the pane shows real pixels the full image is already in view; elsewhere open it once per figure.
167 if (v === 1 && !isFromWatch && (await read($, images)) === 'none') {
168 await openExternal($, `${dir}/current.png`)
169 }
170 return { fig, version, png: rendered.png }
171}
172
173function describe(shown: Shown): string {
174 const { fig, version, png } = shown
175 const pages = version.pages > 1 ? `, page ${fig.page}/${version.pages}` : ''
176 return [
177 `Shown in the iris pane as #${fig.n} "${fig.title}" v${version.v}${pages}.`,
178 `PNG: ${png}`,
179 'Now Read that PNG and tell the user what is actually visible before relying on it.',
180 ].join('\n')
181}
182
183async function select($: EngineInterface, n: number, v?: number) {
184 const fig = (await read($, figures)).find(f => f.n === n)
185 if (!fig) return false
186 const last = fig.versions.length
187 await update($, sel, () => ({ n, v: Math.min(Math.max(1, v ?? last), last) }))
188 await update($, zoom, () => FIT)
189 return true
190}
191
192/** Shows another page of the selected version, rendering it on first use. */
193async function flipPage($: EngineInterface, delta: number) {
194 const s = await read($, sel)
195 const fig = (await read($, figures)).find(f => f.n === s?.n)
196 if (fig) await gotoPage($, fig.page + delta)
197}
198
199async function gotoPage($: EngineInterface, target: number) {
200 const s = await read($, sel)
201 const fig = (await read($, figures)).find(f => f.n === s?.n)
202 const version = fig?.versions[(s?.v ?? 1) - 1]
203 if (!fig || !version?.pdf) return
204 const page = Math.min(Math.max(1, target), version.pages)
205 if (page === fig.page) return
206 let png = pngOf(version, page)
207 if (png === undefined) {
208 png = (await renderPdfPage($, version.pdf, page, `${figDir(fig.n)}/v${version.v}-p${page}`)).png
209 }
210 const updated: IrisVersion = { ...version, png: { ...version.png, [String(page)]: png } }
211 await update($, figures, all =>
212 all.map(f =>
213 f.n === fig.n
214 ? { ...f, page, versions: f.versions.map(one => (one.v === version.v ? updated : one)) }
215 : f,
216 ),
217 )
218}
219
220/** Re-renders watched files whose modification time moved. */
221async function poll($: EngineInterface) {
222 if (isPolling) return
223 isPolling = true
224 try {
225 for (const fig of await read($, figures)) {
226 if (!fig.isWatched) continue
227 const stat = await $.fs.stat(fig.path).catch(() => undefined)
228 if (!stat || stat.mtimeMs === fig.mtime || stat.mtimeMs === failedMtime.get(fig.n)) continue
229 try {
230 await show($, { id: fig.id }, true)
231 } catch (err) {
232 failedMtime.set(fig.n, stat.mtimeMs)
233 $.ui.toast(`iris: #${fig.n} changed but failed to render: ${String(err).slice(0, 160)}`)
234 }
235 }
236 } finally {
237 isPolling = false
238 }
239}
240
241async function rasterFor($: EngineInterface, png: string, columns: number, maxRows: number) {
242 const key = `${png}|${columns}|${maxRows}`
243 let cached = rasters.get(key)
244 if (cached === undefined) {
245 cached = await rasterize($, png, columns, maxRows).catch(err => `preview failed: ${String(err).slice(0, 200)}`)
246 rasters.set(key, cached)
247 }
248 return cached
249}
250
251/** Terminals that draw real pixels (kitty graphics protocol). */
252/**
253 * Asks the renderer how the Image just drawn came out, and switches to what the viewing terminal can show.
254 * The session's own environment says nothing about this: a background session is viewed from another terminal.
255 */
256async function probeImages($: EngineInterface, key: string, source: ImageSource, box: { columns: number; rows: number }) {
257 const now = await $.clock.now()
258 if (now - probedAt < 2000) return
259 probedAt = now
260 await $.clock.sleep(300) // let the frame holding the Image land first
261 const r = await $.ui.blit({ requestId: PANE, key, source, columns: box.columns, rows: box.rows }).catch(() => ({ deny: 'failed' }))
262 const deny = r.deny ?? ''
263 const was = await read($, images)
264 let next = was
265 if (deny === '') next = 'png' in source ? 'bytes' : 'file'
266 // "this terminal cannot read files on this machine ... bytes still draw"
267 else if (/cannot read files/.test(deny)) next = 'bytes'
268 // "the terminal draws no placeholder images"; not "every image id is in use", which passes
269 else if (/draws its alt|not drawn under a terminal/.test(deny) && !/in use/.test(deny)) next = 'none'
270 // "has not yet said whether it reads files ... asked now, blit again"
271 else if (/blit again/.test(deny)) probedAt = 0
272 if (next !== was) {
273 await update($, images, () => next)
274 $.ui.invalidate('ui.render')
275 // One line per change, so how a viewer was judged can be checked afterwards.
276 await sh($, ['sh', '-c', 'printf "%s %s -> %s (%s)\\n" "$(date -Is)" "$1" "$2" "$3" >> "$4"', 'iris', was, next, deny || 'drawn', `${base}/images.log`]).catch(() => undefined)
277 }
278}
279
280/** Whether to draw an Image: yes unless this viewer showed none. A new viewport may be a new viewer, so ask again. */
281async function imagesFor($: EngineInterface, viewport: { columns: number; rows: number } | undefined): Promise<boolean> {
282 const vp = `${viewport?.columns}x${viewport?.rows}`
283 if (vp !== probedViewport) {
284 probedViewport = vp
285 if ((await read($, images)) === 'none') await update($, images, () => 'unknown' as IrisImages)
286 }
287 return (await read($, images)) !== 'none'
288}
289
290async function cachedDims($: EngineInterface, file: string) {
291 let found = dims.get(file)
292 if (found === undefined) {
293 found = await dimsOf($, file)
294 dims.set(file, found)
295 }
296 return found
297}
298
299/** The file the pane shows for the current zoom: the whole render, or a cropped window of it. */
300async function zoomed($: EngineInterface, png: string, z: IrisZoom) {
301 const full = await cachedDims($, png)
302 if (z.z <= 1) return { file: png, ...full }
303 return crop($, png, full.w, full.h, z.z, z.cx, z.cy)
304}
305
306const ZOOM_STEP = 1.5
307const MAX_ZOOM = 12
308
309function zoomBy(factor: number) {
310 return (z: IrisZoom): IrisZoom => {
311 const next = Math.min(MAX_ZOOM, Math.max(1, z.z * factor))
312 return next <= 1.01 ? FIT : { ...z, z: next }
313 }
314}
315
316/** Moves the view by a quarter of its own size. */
317function panBy(dx: number, dy: number) {
318 return (z: IrisZoom): IrisZoom => {
319 const half = 0.5 / z.z
320 const clamp = (c: number) => Math.min(1 - half, Math.max(half, c))
321 return { ...z, cx: clamp(z.cx + (dx * 0.25) / z.z), cy: clamp(z.cy + (dy * 0.25) / z.z) }
322 }
323}
324
325async function sourceText($: EngineInterface, fig: IrisFigure) {
326 if (!TEXT_EXTS.includes(extOf(fig.path))) return `${fig.path}\n(binary file: no source to show)`
327 const text = await $.fs.read(fig.path).catch(() => '(source file no longer readable)')
328 const lines = text.split('\n')
329 return lines.length > 200 ? `${lines.slice(0, 200).join('\n')}\n… ${lines.length - 200} more lines` : text
330}
331
332export const register: Register = on => {
333 on('session.start', async ($, e, next) => {
334 cwd = e.cwd
335 base = `${(await $.env.get('TMPDIR')) ?? '/tmp'}/iris-${await $.session.id()}`
336 await ensureBase($)
337 // Renders outlive the process so a resumed session keeps its figures; old ones are pruned here.
338 await sh($, ['sh', '-c', 'find "$1" -maxdepth 1 -name "iris-*" -mtime +7 -exec rm -rf {} +', 'iris', dirname(base)]).catch(
339 () => undefined,
340 )
341 await $.tool.register({ name: 'show', description: SHOW_DESCRIPTION, inputSchema: SHOW_SCHEMA, isDeferred: false })
342 await $.command.register({
343 name: 'show',
344 description: 'Open the iris pane. /show <n> selects figure n; /show <path> shows a file.',
345 argumentHint: '[n | path]',
346 })
347 watch?.cancel()
348 watch = $.clock.every(1000, () => void poll($))
349
350 return next(e)
351 })
352
353 on('tool.call', { tool: TOOL }, async ($, e) => {
354 const { tool: _tool, tool_use_id: _id, ...args } = e as unknown as ShowArgs & { tool: string; tool_use_id: string }
355 try {
356 return { result: describe(await show($, args)) }
357 } catch (err) {
358 return { deny: `iris could not render it: ${err instanceof Error ? err.message : String(err)}` }
359 }
360 }).catch(() => ({ deny: 'iris failed while handling the call.' }))
361
362 on('command.run', { command: 'show' }, async ($, e) => {
363 const arg = e.args.trim()
364 let text = 'iris pane opened.'
365 if (arg === 'probe') {
366 isProbe = !isProbe
367 $.ui.invalidate('ui.render')
368 text = `iris probe ${isProbe ? 'on' : 'off'}.`
369 } else if (/^#?\d+$/.test(arg)) {
370 text = (await select($, Number(arg.replace('#', '')))) ? `Showing #${arg.replace('#', '')}.` : `No figure ${arg}.`
371 } else if (arg !== '') {
372 try {
373 const shown = await show($, { path: arg })
374 text = `Showing #${shown.fig.n} ${shown.fig.title}.`
375 } catch (err) {
376 text = `iris could not render ${arg}: ${err instanceof Error ? err.message : String(err)}`
377 }
378 }
379 await $.ui.open({ id: PANE, title: 'iris' })
380
381 return { text }
382 })
383
384 on('prompt.submit', async ($, e, next) => {
385 const result = await next(e)
386 const s = await read($, sel)
387 const fig = (await read($, figures)).find(f => f.n === s?.n)
388 const version = fig?.versions[(s?.v ?? 1) - 1]
389 if (result.drop !== undefined || !fig || !version) return result
390 const line = `iris pane shows #${fig.n} "${fig.title}" v${version.v}/${fig.versions.length} (png: ${pngOf(version, fig.page) ?? ''})`
391
392 return { ...result, context: [...(result.context ?? []), line] }
393 }).catch(($, e, next) => next(e))
394
395 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
396 if (e.props.tool !== TOOL) return next(e)
397 const { Box, Text } = $.ui.resolve(e)
398 const a = (e.props.input ?? {}) as ShowArgs
399 const what = a.title ?? (a.path !== undefined ? basename(a.path) : a.mermaid !== undefined ? 'Mermaid diagram' : a.dot !== undefined ? 'Graphviz diagram' : a.id ?? 'figure')
400 const extra = [a.id !== undefined ? `id ${a.id}` : '', a.page !== undefined ? `page ${a.page}` : ''].filter(Boolean).join(' · ')
401 return (
402 <Box flexDirection="row">
403 <Text bold color="#4E79A7">iris</Text>
404 <Text dimColor> · Show {what}{extra !== '' ? ` · ${extra}` : ''}</Text>
405 </Box>
406 )
407 }).catch(($, e, next) => next(e))
408
409 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
410 const { Box, Text, Button, Code } = $.ui.resolve(e)
411 const list = await read($, figures)
412 const s = await read($, sel)
413 const mode = await read($, view)
414 const fig = list.find(f => f.n === s?.n)
415 const version = fig?.versions[(s?.v ?? 1) - 1]
416
417 if (!fig || !version) {
418 return (
419 <Box flexDirection="column">
420 <Text dimColor>No figures yet. Claude adds them with the show tool; you can type /show path/to/file.</Text>
421 </Box>
422 )
423 }
424
425 const z = await read($, zoom)
426 const columns = Math.max(10, e.props.bodyColumns)
427 const png = pngOf(version, fig.page) ?? ''
428 const count = fig.versions.length
429 // Fit the picture into what the pane shows at once: its body less the header and button rows.
430 const chrome = 3 + (fig.caption === undefined ? 0 : 1) + (z.z > 1 ? 1 : 0) + (version.pages > 1 ? 1 : 0)
431 const fitRows = Math.max(4, e.props.scroll.bodyRows - chrome)
432 const zoomLabel = z.z > 1 ? ` · ${z.z.toFixed(1)}×` : ''
433
434 let body
435 let indent = 0
436 if (mode === 'source') {
437 body = <Code source={await sourceText($, fig)} language={extOf(fig.path)} />
438 } else if (e.surface === 'terminal') {
439 const shown = await zoomed($, png, z).catch(() => undefined)
440 if (!shown) {
441 body = <Text dimColor>Preview failed. Press o to open the file.</Text>
442 } else if (await imagesFor($, e.viewport)) {
443 const how = await read($, images)
444 const { Image } = $.ui.resolve(e)
445 const box = fitCells(shown.w, shown.h, columns, fitRows)
446 indent = Math.max(0, Math.floor((columns - box.columns) / 2))
447 if (isProbe) renderLog.push(
448 JSON.stringify({
449 t: await $.clock.now(),
450 bodyColumns: e.props.bodyColumns,
451 bodyRows: e.props.scroll.bodyRows,
452 scrollOffset: e.props.scroll.offset,
453 placement: e.props.placement,
454 viewport: e.viewport,
455 image: { w: shown.w, h: shown.h },
456 box,
457 indent,
458 fitRows,
459 }),
460 )
461 if (renderLog.length > 40) renderLog.shift()
462 if (isProbe) void $.fs.write(`${base}/render-log.jsonl`, renderLog.join('\n') + '\n')
463 // A keyed Image keeps its first placement size; a new key per box size makes a resize re-place it.
464 const key = `preview-${box.columns}x${box.rows}`
465 // Same file at a new size must be re-sent: a source equal to the last one sends nothing.
466 const source: ImageSource =
467 how === 'bytes' ? { png: (await $.fs.read(shown.file, { as: 'bytes' })).base64 } : { file: shown.file, format: 'png', generation: box.columns * 1000 + box.rows }
468 body = <Image key={key} source={source} columns={box.columns} rows={box.rows} alt={`#${fig.n} ${fig.title} (press o to open)`} />
469 void probeImages($, key, source, box).catch(() => undefined)
470 if (isProbe) {
471 const half = fitCells(shown.w, shown.h, columns, Math.max(4, Math.floor(fitRows / 2) - 2))
472 const { base64 } = await $.fs.read(shown.file, { as: 'bytes' })
473 body = (
474 <Box flexDirection="column">
475 <Text dimColor>Probe A: file source {half.columns}x{half.rows}</Text>
476 <Image key="probe-file" source={{ file: shown.file, format: 'png', generation: half.columns * 1000 + half.rows }} columns={half.columns} rows={half.rows} alt="file" />
477 <Text dimColor>Probe B: PNG bytes {half.columns}x{half.rows}</Text>
478 <Image key="probe-png" source={{ png: base64 }} columns={half.columns} rows={half.rows} alt="png" />
479 </Box>
480 )
481 }
482 } else {
483 const { Raster } = $.ui.resolve(e)
484 const raster = await rasterFor($, shown.file, columns, fitRows)
485 body =
486 typeof raster === 'string' ? (
487 <Text dimColor>{raster}</Text>
488 ) : (
489 <Raster key="preview" columns={raster.columns} rows={raster.rows} cells={raster.cells} />
490 )
491 }
492 } else {
493 body = <Text dimColor>Preview is drawn in the terminal only. Press Open for the full image.</Text>
494 }
495
496 const recent = list.slice(-8).reverse()
497 const target = version.vector ?? `${figDir(fig.n)}/current.png`
498
499 return (
500 <Box flexDirection="column">
501 <Text bold>
502 #{fig.n} {fig.title}{' '}
503 <Text dimColor>
504 v{version.v}/{count}
505 {version.pages > 1 ? ` · page ${fig.page}/${version.pages}` : ''}
506 {fig.pageTitles?.[fig.page - 1] !== undefined ? ` · ${fig.pageTitles[fig.page - 1]}` : ''}
507 {zoomLabel}
508 </Text>
509 </Text>
510 {fig.caption !== undefined && <Text dimColor>{fig.caption}</Text>}
511 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
512 <Button key="open" hotkey="o" label="Open" onPress={() => openExternal($, target)} />
513 <Button
514 key="source"
515 hotkey="s"
516 label={mode === 'source' ? 'Preview' : 'Source'}
517 onPress={() => update($, view, m => (m === 'source' ? 'preview' : 'source'))}
518 />
519 <Button key="zoomin" hotkey="z" label="Zoom +" onPress={() => update($, zoom, zoomBy(ZOOM_STEP))} />
520 {z.z > 1 && <Button key="zoomout" hotkey="x" label="Zoom −" onPress={() => update($, zoom, zoomBy(1 / ZOOM_STEP))} />}
521 {z.z > 1 && <Button key="fit" hotkey="0" label="Fit" onPress={() => update($, zoom, () => FIT)} />}
522 {count > 1 && <Button key="prev" hotkey="p" label="‹ Older" onPress={() => select($, fig.n, version.v - 1)} />}
523 {count > 1 && <Button key="next" hotkey="n" label="Newer ›" onPress={() => select($, fig.n, version.v + 1)} />}
524 {version.pages > 1 && <Button key="pageup" hotkey="b" label="‹ Page" onPress={() => flipPage($, -1)} />}
525 {version.pages > 1 && <Button key="pagedown" hotkey="f" label="Page ›" onPress={() => flipPage($, 1)} />}
526 </Box>
527 {version.pages > 1 && version.pages <= 40 && (
528 // One click per page: rapid repeated clicks read as a double-click and select text.
529 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
530 {Array.from({ length: version.pages }, (_, i) => i + 1).map(p => (
531 <Button key={`page-${p}`} plain dimColor={p !== fig.page} onPress={() => gotoPage($, p)}>
532 {p === fig.page ? `[${p}]` : String(p)}
533 </Button>
534 ))}
535 </Box>
536 )}
537 {z.z > 1 && (
538 <Box flexDirection="row" columnGap={1}>
539 <Text dimColor>Pan</Text>
540 <Button key="left" hotkey="h" label="←" onPress={() => update($, zoom, panBy(-1, 0))} />
541 <Button key="down" hotkey="j" label="↓" onPress={() => update($, zoom, panBy(0, 1))} />
542 <Button key="up" hotkey="k" label="↑" onPress={() => update($, zoom, panBy(0, -1))} />
543 <Button key="right" hotkey="l" label="→" onPress={() => update($, zoom, panBy(1, 0))} />
544 </Box>
545 )}
546 <Box flexDirection="row" paddingLeft={indent}>
547 {body}
548 </Box>
549 {recent.length > 1 && <Text dimColor>History</Text>}
550 {recent.length > 1 &&
551 recent.map(one => (
552 <Button key={`fig-${one.n}`} plain dimColor={one.n !== fig.n} onPress={() => select($, one.n)}>
553 #{one.n} {one.title} <Text dimColor>v{one.versions.length}</Text>
554 </Button>
555 ))}
556 </Box>
557 )
558 })
559}
560hooks/render.ts 175 lines1// Turns a source file into a PNG with the converters installed on this machine.
2// Every converter writes its output to a file: $.process hands stdout back as text.
3
4import type { EngineInterface } from 'claude-code'
5
6import { packHalfBlocks, type RasterCells } from './raster'
7
8export const SOURCE_EXTS = ['mmd', 'dot', 'gv', 'tex', 'typ', 'pdf', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
9export const TEXT_EXTS = ['mmd', 'dot', 'gv', 'tex', 'typ', 'svg']
10
11export function extOf(path: string): string {
12 const dot = path.lastIndexOf('.')
13 return dot < 0 ? '' : path.slice(dot + 1).toLowerCase()
14}
15
16export function basename(path: string): string {
17 return path.slice(path.lastIndexOf('/') + 1)
18}
19
20export function dirname(path: string): string {
21 const slash = path.lastIndexOf('/')
22 return slash <= 0 ? '/' : path.slice(0, slash)
23}
24
25export function parsePages(pdfinfo: string): number {
26 const m = /^Pages:\s+(\d+)/m.exec(pdfinfo)
27 return m ? Number(m[1]) : 1
28}
29
30/** Last lines of a failed command's output, enough to see the error. */
31export function tail(text: string, lines = 25): string {
32 return text.trimEnd().split('\n').slice(-lines).join('\n')
33}
34
35async function run($: EngineInterface, argv: string[], cwd?: string, timeoutMs = 120_000) {
36 const out = await $.process.run(argv, { cwd, timeoutMs })
37 if (out.exitCode !== 0) {
38 throw new Error(`${argv[0]} failed (exit ${out.exitCode}):\n${tail(out.stderr || out.stdout)}`)
39 }
40 return out
41}
42
43export type Rendered = { pdf?: string; vector?: string; pages: number; page: number; png: string }
44
45/** Renders page `page` of a PDF to `<base>.png`. */
46export async function renderPdfPage($: EngineInterface, pdf: string, page: number, base: string): Promise<Rendered> {
47 const pages = parsePages((await run($, ['pdfinfo', pdf])).stdout)
48 const p = Math.min(Math.max(1, page), pages)
49 await run($, ['pdftoppm', '-png', '-r', '200', '-f', String(p), '-l', String(p), '-singlefile', pdf, base])
50 return { pdf, vector: pdf, pages, page: p, png: `${base}.png` }
51}
52
53/**
54 * Renders `src` into `dir` as version `v`. `puppeteer` is the config file
55 * mmdc needs on this machine (Chrome's sandbox is blocked by AppArmor).
56 */
57export async function convert(
58 $: EngineInterface,
59 src: string,
60 dir: string,
61 v: number,
62 page: number,
63 puppeteer: string,
64): Promise<Rendered> {
65 await run($, ['mkdir', '-p', dir])
66 const png = `${dir}/v${v}-p1.png`
67 const one = (vector?: string): Rendered => ({ pages: 1, page: 1, png, vector })
68 const svg = `${dir}/v${v}.svg`
69 const pdf = `${dir}/v${v}.pdf`
70 const ext = extOf(src)
71
72 switch (ext) {
73 case 'mmd':
74 await run($, ['mmdc', '-p', puppeteer, '-i', src, '-o', png, '-b', 'white', '-s', '3', '-q'])
75 await run($, ['mmdc', '-p', puppeteer, '-i', src, '-o', svg, '-b', 'white', '-q'])
76 return one(svg)
77 case 'dot':
78 case 'gv':
79 await run($, ['dot', '-Tpng', '-Gdpi=200', src, '-o', png])
80 await run($, ['dot', '-Tsvg', src, '-o', svg])
81 return one(svg)
82 case 'svg':
83 await run($, ['magick', '-density', '200', '-background', 'white', src, '-flatten', png])
84 await run($, ['cp', src, svg])
85 return one(svg)
86 case 'png':
87 await run($, ['cp', src, png])
88 return one()
89 case 'jpg':
90 case 'jpeg':
91 case 'gif':
92 case 'webp':
93 case 'bmp':
94 await run($, ['magick', `${src}[0]`, png])
95 return one()
96 case 'pdf':
97 await run($, ['cp', src, pdf])
98 break
99 case 'tex': {
100 const out = `${dir}/tex`
101 await run($, ['mkdir', '-p', out])
102 await run(
103 $,
104 ['pdflatex', '-interaction=nonstopmode', '-halt-on-error', `-output-directory=${out}`, basename(src)],
105 dirname(src),
106 )
107 await run($, ['cp', `${out}/${basename(src).replace(/\.tex$/i, '.pdf')}`, pdf])
108 break
109 }
110 case 'typ':
111 await run($, ['typst', 'compile', src, pdf])
112 break
113 default:
114 throw new Error(`iris can't show .${ext} files (supported: ${SOURCE_EXTS.join(', ')})`)
115 }
116 return renderPdfPage($, pdf, page, `${dir}/v${v}-p${page}`)
117}
118
119/** Pixel width and height of an image file. */
120export async function dimsOf($: EngineInterface, file: string): Promise<{ w: number; h: number }> {
121 const out = await run($, ['magick', 'identify', '-format', '%w %h', `${file}[0]`])
122 const [w, h] = out.stdout.trim().split(/\s+/).map(Number)
123 return { w: w || 1, h: h || 1 }
124}
125
126/** The window of a zoomed view, in pixels, kept inside the image. */
127export function cropWindow(w: number, h: number, z: number, cx: number, cy: number) {
128 const cw = Math.max(1, Math.round(w / z))
129 const ch = Math.max(1, Math.round(h / z))
130 const x = Math.min(Math.max(0, Math.round(cx * w - cw / 2)), w - cw)
131 const y = Math.min(Math.max(0, Math.round(cy * h - ch / 2)), h - ch)
132 return { cw, ch, x, y }
133}
134
135/** Crops `png` to the zoomed window, caching the result beside it. */
136export async function crop($: EngineInterface, png: string, w: number, h: number, z: number, cx: number, cy: number) {
137 const { cw, ch, x, y } = cropWindow(w, h, z, cx, cy)
138 const out = `${png}.crop-${cw}x${ch}+${x}+${y}.png`
139 if (!(await $.fs.exists(out))) await run($, ['magick', png, '-crop', `${cw}x${ch}+${x}+${y}`, '+repage', out])
140 return { file: out, w: cw, h: ch }
141}
142
143/** Cells that show a w x h picture whole inside columns x maxRows; a cell is about twice as tall as wide. */
144export function fitCells(w: number, h: number, columns: number, maxRows: number) {
145 const aspect = h / w
146 let cols = Math.min(columns, 255)
147 let rows = Math.max(1, Math.round((cols * aspect) / 2))
148 if (rows > maxRows) {
149 rows = Math.max(1, maxRows)
150 cols = Math.max(1, Math.min(cols, Math.round((rows * 2) / aspect)))
151 }
152 return { columns: cols, rows: Math.min(rows, 255) }
153}
154
155/** Shrinks a PNG to fit `columns` x `maxRows` cells and packs it as half blocks. */
156export async function rasterize(
157 $: EngineInterface,
158 png: string,
159 columns: number,
160 maxRows: number,
161): Promise<RasterCells> {
162 const raw = `${png}.${columns}x${maxRows}.rgb`
163 const out = await run($, [
164 'magick', png,
165 '-background', 'white', '-alpha', 'remove', '-alpha', 'off',
166 '-resize', `${columns}x${maxRows * 2}`,
167 '-dither', 'None', '-colors', '128',
168 '-print', '%w %h\n',
169 '-depth', '8', `rgb:${raw}`,
170 ])
171 const [w, h] = out.stdout.trim().split(/\s+/).map(Number)
172 const { base64 } = await $.fs.read(raw, { as: 'bytes' })
173 return packHalfBlocks(Uint8Array.fromBase64(base64), w ?? columns, h ?? 1)
174}
175hooks/raster.ts 27 lines1// Packs raw RGB pixels into Raster cells: one upper-half-block glyph per cell,
2// its foreground the top pixel and its background the bottom one.
3
4export const UPPER_HALF = 0x2580
5export const TERMINAL_DEFAULT = 0x01000000
6
7export type RasterCells = { cells: string; columns: number; rows: number }
8
9export function packHalfBlocks(rgb: Uint8Array, width: number, height: number): RasterCells {
10 const rows = Math.ceil(height / 2)
11 const words = new Uint32Array(width * rows * 3)
12 const pixel = (x: number, y: number) => {
13 if (y >= height) return TERMINAL_DEFAULT
14 const i = (y * width + x) * 3
15 return ((rgb[i] ?? 0) << 16) | ((rgb[i + 1] ?? 0) << 8) | (rgb[i + 2] ?? 0)
16 }
17 for (let r = 0; r < rows; r++) {
18 for (let x = 0; x < width; x++) {
19 const w = (r * width + x) * 3
20 words[w] = UPPER_HALF
21 words[w + 1] = pixel(x, 2 * r)
22 words[w + 2] = pixel(x, 2 * r + 1)
23 }
24 }
25 return { cells: new Uint8Array(words.buffer).toBase64(), columns: width, rows }
26}
27types/index.d.ts 56 lines1/** One render of a figure. PDFs keep a snapshot so pages can be flipped later. */
2export type IrisVersion = {
3 v: number
4 at: number
5 /** Snapshot of the PDF this version came from (pdf, tex, typ sources). */
6 pdf?: string
7 pages: number
8 /** The vector original Open shows (svg or pdf), zoomable in a viewer. */
9 vector?: string
10 /** Rendered PNG per page, keyed by page number as a string. */
11 png: Record<string, string>
12}
13
14export type IrisFigure = {
15 /** Display number, #1, #2, ... in order of creation. */
16 n: number
17 /** Stable id; showing the same id again adds a version. */
18 id: string
19 title: string
20 caption?: string
21 /** Absolute path of the source file (inline mermaid/dot is written to one). */
22 path: string
23 /** True when the source is the user's file, so iris watches it for changes. */
24 isWatched: boolean
25 page: number
26 /** Optional title per PDF page, shown in the header for the current page. */
27 pageTitles?: string[]
28 mtime?: number
29 versions: IrisVersion[]
30}
31
32export type IrisSelection = { n: number; v: number } | null
33
34/** In-pane zoom: z = 1 fits the whole image; cx, cy are the view's centre as fractions. */
35export type IrisZoom = { z: number; cx: number; cy: number }
36
37/**
38 * How the terminal that views the session shows an Image, learned by asking the renderer (not from the
39 * environment: a background session's environment is not its viewer's). file: pixels from a path;
40 * bytes: pixels only when sent as PNG bytes (the viewer cannot read this machine's files); none: no
41 * pixels, so draw half-block cells; unknown: not asked yet.
42 */
43export type IrisImages = 'unknown' | 'file' | 'bytes' | 'none'
44
45declare module 'claude-code' {
46 interface PluginState {
47 iris: {
48 figures: IrisFigure[]
49 sel: IrisSelection
50 view: 'preview' | 'source'
51 zoom: IrisZoom
52 images: IrisImages
53 }
54 }
55}
56