SLOPSHOPPER

iris

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.

newpanerowsguardcommandtoast
★ 4v0.1.0MITupdated 2026-10-15braininahat/brains-in-a-hat/iris
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · iris
│ ┃ iris ✕ › fix the failing auth test and add an audit log call │ ┃ No figures yet. Claude adds them with the │ ┃ show tool; you can type /show path/to/file. ⏺ 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 │ │ › /show │ ⎿ iris: iris pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · iris
No figures yet. Claude adds them with the show tool; you can type /show path/to/file.
README

iris

A Claude Code mod that shows diagrams and images in a side pane, and makes Claude look at every render.

  • Claude calls 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.
  • Reusing an id adds versions (v2, v3 …); watched files re-render when they change on disk.
  • Real pixels in terminals with the kitty graphics protocol (Ghostty, kitty); a half-block colour preview elsewhere.
  • Pane keys: 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.

Source 4 files
hooks/register.tsx 560 lines
1import { 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}
560
hooks/render.ts 175 lines
1// 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}
175
hooks/raster.ts 27 lines
1// 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}
27
types/index.d.ts 56 lines
1/** 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