Draw diagrams, LaTeX math, and tool-result images inline in kitty-graphics terminals

A Claude Code mod that draws diagrams, LaTeX math, and tool-result images inline in the terminal transcript, using the kitty graphics protocol.

mermaid, dot (or graphviz), d2, or svg in Claude's reply is rendered and drawn under the reply. The code stays visible above the picture.$$ … $$, \[ … \], or a math, latex, or tex code block, is typeset with MathJax. Inline $…$ stays text, since a picture cannot sit inside a line, and a latex block holding a whole document (\documentclass) stays code.Read on a PNG, JPEG, GIF, WebP, or SVG file, or a screenshot an MCP tool returns, is drawn under the tool's row, where Claude Code otherwise shows only a size line; an SVG read only in part is left alone. In a collapsed tool group they are thumbnails; ctrl+o unfolds the group and draws them full size. A PNG, JPEG, GIF, WebP, or SVG Claude sends to another device with SendUserFile is drawn under its attachment line too, so it is on screen when you come back to the terminal. It is read from disk when the row is first drawn; overwriting the file later leaves that picture as it was.A picture the size caps would shrink below 60% of its natural size grows up to the terminal's height, and one still smaller says so under it, such as shown at 43% · /figures to enlarge.
/figures opens a pane with the session's pictures, starting on the newest one drawn too small to read, or else the newest. p and n step through them, o opens the current one with open (macOS) or xdg-open, and Escape or your next prompt closes the pane.

The plugin also ships a diagrams skill, which tells Claude which languages are drawn and how to write them so they render.
Each rendered PNG is cached under $TMPDIR/claude-figures/, named by a hash of the source and the theme, so a resumed session draws its pictures again without re-rendering. Files older than seven days are removed when a session starts.
Claude Code 2.1.287 or later, with mods (function-hook plugins) enabled, in a terminal it treats as kitty-graphics capable; see Terminal support. Each kind of picture also needs programs on PATH:
| Picture | Needs |
|---|---|
mermaid | mmdr or mmdc; resvg recommended |
dot, graphviz | Graphviz's dot and resvg |
d2 | d2 and resvg |
svg, math | resvg |
| Tool images other than PNG | magick (ImageMagick) or sips (built into macOS) |
scale option.claude plugin marketplace add natsukium/claude-code-figures-plugin
claude plugin install figures@figures
Install mmdr with cargo install mermaid-rs-renderer.
Set these with /config or claude plugin configure figures.
| Option | Default | Meaning |
|---|---|---|
theme | auto | auto follows Claude Code's theme; or dark, default, forest, neutral, modern (mmdr only; mmdc draws it as default). Math is drawn light on dark and dark otherwise |
background | transparent | Diagram background; transparent lets the terminal's background show through |
scale | 2 | Pixel density of the picture; above 1 needs resvg |
cell_width_px | 8 | Assumed width of one terminal cell, in the pixels diagrams are laid out in |
cell_aspect | 2.1 | Assumed height-to-width ratio of one terminal cell |
max_columns | 120 | Widest a picture may be, in cells; it is also kept inside the terminal |
max_rows | 30 | Tallest a picture may be before it is grown for legibility, in cells |
renderers | (empty) | Renderer ids in preference order, comma-separated; see below |
tool_images | true | Draw images found in tool results |
tool_image_max_rows | 12 | Tallest a tool-result thumbnail may be, in cells |
renderers picks and orders the programs that draw each language. The ids are mmdr and mmdc (mermaid), dot, d2, svg, and mathjax. A language with any of its renderers listed uses only those, in the listed order; the others keep the default. So mmdc draws mermaid with mmdc alone, and mmdc,mmdr tries mmdc first. An id no renderer has is reported when a session starts.
Claude Code enables pictures only when the terminal's XTVERSION reply names kitty (0.28.0 or later) or Ghostty. A terminal that answers the kitty graphics query correctly under any other name still gets the alt text, such as [mermaid diagram 1]. Setting TERM or TERM_PROGRAM has no effect.
To use the plugin on such a terminal, set the override Claude Code reads:
CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 claude
This also skips Claude Code's tmux check. Inside tmux, the pictures additionally need set -g allow-passthrough on.
To see what Claude Code decided, start it with --debug and look for the Terminal capabilities: line in the debug log.
cell_width_px and cell_aspect, since mods are not told the terminal's cell size in pixels. Adjust them if pictures look stretched.theme=auto follows Claude Code's own theme (/config): a light variant draws light pictures, the others dark. When Claude Code's theme is itself auto, its resolved value is not exposed, so COLORFGBG decides where the terminal exports it, and dark otherwise.A --> B -->|ok| C) is misparsed; write one edge per line. Such a block renders without an error, so it is not handed to mmdc; set renderers to mmdc to avoid mmdr.\text{日本語}, is drawn by resvg with a system font./figures pane shares the screen with the prompt, so in a short terminal it has only a few rows; o opens the picture at full size outside the terminal.tsconfig.json extends the engine's typings in .claude-plugin/types/, which Vite and Vitest read as well as tsc. Claude Code writes them only when it loads the plugin from this folder, and no command writes them alone, so pnpm types starts claude -p --plugin-dir . with a dummy API key and ignores the failed request once the typings exist. build, test, and typecheck run it first. Claude Code is a dev dependency, so the scripts and pnpm exec claude use the version pinned in pnpm-lock.yaml, whatever claude is on PATH.
pnpm install
pnpm build # regenerate hooks/vendor/
pnpm test # unit tests (Vitest, tests/unit/*.spec.ts)
pnpm typecheck
pnpm exec claude plugin validate .
pnpm exec claude plugin test . # hooks through the engine (tests/engine/*.test.ts)
Claude Code loads hooks/ as TypeScript source, so only third-party code is built. pnpm build bundles MathJax and @noble/hashes with Vite into hooks/vendor/, which is committed so the repository stays installable as is. A hooks module imports no file over 1 MiB and cannot import modules at run time, so the build splits the bundle into chunks and writes each of the font's glyph ranges as JSON, which the plugin reads the first time a formula uses it.
The engine refuses a module that passes $ across an import, so hooks/register.tsx wraps what the plugin needs from $ in an Io object (hooks/io.ts), and every other module takes that.
Apache-2.0. hooks/vendor/ is built from MathJax and its New Computer Modern font, both Apache-2.0, and @noble/hashes, MIT.
hooks/register.tsx 108 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import { parseConfig } from './config.ts'
5import type { Figures } from './figures.ts'
6import type { Io } from './io.ts'
7import { Memo, cacheDir, expire } from './pipeline/cache.ts'
8import { Pipeline } from './pipeline/index.ts'
9import { createRegistry } from './renderers/index.ts'
10import { replyPictures, toolGroupPictures, toolUsePictures } from './ui/inline.tsx'
11import { PANE, drawPane } from './ui/pane.tsx'
12import { type TerminalElements, type pictures, viewportOf } from './ui/pictures.tsx'
13
14const selected = atom({ plugin: 'figures', key: 'selected' } as const, null)
15
16function ioOf($: EngineInterface): Io {
17 return {
18 run: (argv, init) => $.process.run(argv, init),
19 exists: (path) => $.fs.exists(path),
20 readText: (path) => $.fs.read(path),
21 readBase64: async (path) => (await $.fs.read(path, { as: 'bytes' })).base64,
22 write: (path, text) => $.fs.write(path, text),
23 tmpdir: () => $.env.get('TMPDIR'),
24 colorfgbg: () => $.env.get('COLORFGBG'),
25 claudeTheme: async () => (await $.config.list()).find((row) => row.key === 'theme')?.value,
26 messages: () => $.session.messages(),
27 toast: (text) => $.ui.toast(text),
28 pluginRoot: $.plugin.root,
29 }
30}
31
32export const register: Register = (on, options) => {
33 const config = parseConfig(options)
34 const registry = createRegistry(config.renderers)
35 const figures: Figures = { config, registry, pipeline: new Pipeline(registry), sent: new Memo() }
36
37 on('session.start', async ($, e, next) => {
38 const io = ioOf($)
39 await expire(io, await cacheDir(io))
40 await $.command.register({ name: PANE, description: 'Browse the diagrams and images drawn in this session' })
41 if (registry.unknown.length > 0) {
42 $.ui.toast(`figures: unknown renderer ${registry.unknown.join(', ')} in the renderers option`)
43 }
44 return next(e)
45 })
46
47 on('command.run', { command: PANE }, async ($) => {
48 // The pane is where a picture too small inline is enlarged, so it asks for
49 // all the height the layout can spare rather than max_rows.
50 await $.ui.open({ id: PANE, title: 'Figures', focus: true, closeOnEscape: true, rows: 255 })
51 return { text: 'Figures pane opened: p/n move, o opens in the system viewer.' }
52 })
53
54 // Only the person's own prompt dismisses it; a notification or peer
55 // delivery is not the person moving on.
56 on('prompt.submit', async ($, e, next) => {
57 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') await $.ui.close({ id: PANE })
58 return next(e)
59 })
60
61 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
62 if (e.surface !== 'terminal') return next(e)
63 const selection = { index: await read($, selected), set: (index: number) => update($, selected, () => index) }
64 return drawPane(ioOf($), figures, $.ui.resolve(e), e.props, viewportOf(e), selection)
65 })
66
67 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
68 if (e.surface !== 'terminal') return next(e)
69 const el = $.ui.resolve(e)
70 return under(el, () => next(e), await replyPictures(ioOf($), figures, el, e.props.text, viewportOf(e)))
71 })
72
73 // SendUserFile's ToolUse row draws nothing of its own, so a picture hung
74 // under it lands above the attachment line its ToolResult draws.
75 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
76 if (e.surface !== 'terminal' || e.props.tool !== 'SendUserFile' || e.props.isErrored) return next(e)
77 const el = $.ui.resolve(e)
78 return under(el, () => next(e), await toolUsePictures(ioOf($), figures, el, e.props, viewportOf(e)))
79 })
80
81 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
82 if (e.surface !== 'terminal' || e.props.isRunning || e.props.output === undefined) return next(e)
83 if (e.props.tool === 'SendUserFile') return next(e)
84 const el = $.ui.resolve(e)
85 return under(el, () => next(e), await toolUsePictures(ioOf($), figures, el, e.props, viewportOf(e)))
86 })
87
88 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
89 if (e.surface !== 'terminal' || e.props.isExpanded) return next(e)
90 const el = $.ui.resolve(e)
91 return under(el, () => next(e), await toolGroupPictures(ioOf($), figures, el, e.props.calls, viewportOf(e)))
92 })
93}
94
95async function under<Row extends RenderChildren>(
96 { Box }: TerminalElements,
97 row: () => Promise<Row>,
98 drawn: ReturnType<typeof pictures> | undefined,
99) {
100 if (drawn === undefined) return row()
101 return (
102 <Box flexDirection="column">
103 {await row()}
104 {drawn}
105 </Box>
106 )
107}
108hooks/config.ts 33 lines1export type Config = {
2 /** `auto` follows Claude Code's theme; anything else is a fixed renderer theme. */
3 theme: string
4 background: string
5 scale: number
6 cellWidthPx: number
7 cellAspect: number
8 maxColumns: number
9 maxRows: number
10 toolImages: boolean
11 toolImageMaxRows: number
12 /** Renderer ids in preference order; see renderers/index.ts. */
13 renderers: string[]
14}
15
16export function parseConfig(options: Record<string, unknown>): Config {
17 return {
18 theme: String(options.theme ?? 'auto'),
19 background: String(options.background ?? 'transparent'),
20 scale: Number(options.scale ?? 2),
21 cellWidthPx: Number(options.cell_width_px ?? 8),
22 cellAspect: Number(options.cell_aspect ?? 2.1),
23 maxColumns: Number(options.max_columns ?? 120),
24 maxRows: Number(options.max_rows ?? 30),
25 toolImages: options.tool_images !== false,
26 toolImageMaxRows: Number(options.tool_image_max_rows ?? 12),
27 renderers: String(options.renderers ?? '')
28 .split(',')
29 .map((id) => id.trim())
30 .filter((id) => id !== ''),
31 }
32}
33hooks/figures.ts 75 lines1import type { Config } from './config.ts'
2import { type Block, blocksIn } from './detect/markdown.ts'
3import { type FoundImage, SVG_MIME, imagesIn, readSvgIn, sentFilesIn } from './detect/tool-output.ts'
4import type { Io } from './io.ts'
5import type { Memo } from './pipeline/cache.ts'
6import type { Outcome, Pipeline } from './pipeline/index.ts'
7import type { Registry } from './renderers/index.ts'
8import { resolveTheme } from './theme.ts'
9
10/** What every hook shares: the options, the renderers they chose, the pipeline, and the files sent so far. */
11export type Figures = { config: Config; registry: Registry; pipeline: Pipeline; sent: Memo<ToolPicture[]> }
12
13export const blocksOf = (figures: Figures, text: string): Block[] => blocksIn(text, figures.registry)
14
15export async function renderBlocks(io: Io, figures: Figures, blocks: Block[]): Promise<Outcome[]> {
16 const { config, pipeline } = figures
17 const theme = await resolveTheme(io, config.theme)
18 return Promise.all(
19 blocks.map((block) =>
20 pipeline.code(io, {
21 lang: block.lang,
22 source: block.source,
23 theme,
24 background: config.background,
25 scale: config.scale,
26 cellWidthPx: config.cellWidthPx,
27 }),
28 ),
29 )
30}
31
32export type ToolCall = { tool_use_id: string; tool: string; output?: unknown }
33
34/** A picture a tool returned or sent: raster bytes, or an SVG's source for the svg renderer. */
35export type ToolPicture = { image: FoundImage } | { svg: string }
36
37export async function renderImages(io: Io, figures: Figures, call: ToolCall): Promise<Outcome[]> {
38 const read = readSvgIn(call.tool, call.output)
39 const files: ToolPicture[] = [
40 ...imagesIn(call.output).map((image) => ({ image })),
41 ...(read === undefined ? [] : [{ svg: read }]),
42 ...(await sentFiles(io, figures, call)),
43 ]
44 return Promise.all(
45 files.map(async (file) =>
46 'image' in file
47 ? figures.pipeline.image(io, file.image)
48 : (await renderBlocks(io, figures, [{ lang: 'svg', source: file.svg }]))[0]!,
49 ),
50 )
51}
52
53// Held per call, not per path: a path sent again after being overwritten
54// must not repaint the earlier rows with the new picture.
55function sentFiles(io: Io, figures: Figures, call: ToolCall): Promise<ToolPicture[]> {
56 const files = sentFilesIn(call.tool, call.output)
57 if (files.length === 0) return Promise.resolve([])
58 return figures.sent.get(call.tool_use_id, async () => {
59 const read = await Promise.all(
60 files.map(async ({ path, mime }): Promise<ToolPicture[]> => {
61 // Moved, deleted, or over the engine's read limit: the send itself
62 // succeeded, so the row stays as the engine draws it.
63 try {
64 return mime === SVG_MIME
65 ? [{ svg: await io.readText(path) }]
66 : [{ image: { base64: await io.readBase64(path), mime } }]
67 } catch {
68 return []
69 }
70 }),
71 )
72 return read.flat()
73 })
74}
75hooks/io.ts 25 lines1import type { EngineInterface, SessionMessage } from 'claude-code'
2
3export type RunResult = Awaited<ReturnType<EngineInterface['process']['run']>>
4
5/**
6 * Everything the plugin reaches outside itself. The engine refuses a module
7 * that passes `$` across an import, so register.tsx builds this from `$` and
8 * the rest of the plugin sees only it.
9 */
10export type Io = {
11 run(argv: string[], init: { stdin: string; timeoutMs: number }): Promise<RunResult>
12 exists(path: string): Promise<boolean>
13 readText(path: string): Promise<string>
14 readBase64(path: string): Promise<string>
15 write(path: string, text: string): Promise<void>
16 tmpdir(): Promise<string | undefined>
17 /** The terminal's `fg;bg` colors, where it exports them. */
18 colorfgbg(): Promise<string | undefined>
19 /** Claude Code's own `theme` setting. */
20 claudeTheme(): Promise<unknown>
21 messages(): Promise<readonly SessionMessage[]>
22 toast(text: string): unknown
23 pluginRoot: string
24}
25hooks/pipeline/cache.ts 39 lines1import type { Io } from '../io.ts'
2import { sha256Hex } from '../vendor/hash.js'
3
4// JSON.stringify keeps insertion order, so keys are sorted to make two equal
5// requests hash alike however they were built.
6const canonical = (value: unknown): string =>
7 JSON.stringify(value, (_, v: unknown) =>
8 v !== null && typeof v === 'object' && !Array.isArray(v)
9 ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
10 : v,
11 )
12
13export const cacheKey = (request: unknown): string => sha256Hex(canonical(request))
14
15export async function cacheDir(io: Io): Promise<string> {
16 const tmp = ((await io.tmpdir()) ?? '/tmp').replace(/\/$/, '')
17 return `${tmp}/claude-figures`
18}
19
20export async function expire(io: Io, dir: string, days = 7): Promise<void> {
21 await io
22 .run(['find', dir, '-name', '*.png', '-mtime', `+${days}`, '-delete'], { stdin: '', timeoutMs: 10_000 })
23 .catch(() => undefined)
24}
25
26/** One promise per key, so a picture drawn again while rendering joins the first render. */
27export class Memo<T> {
28 readonly #entries = new Map<string, Promise<T>>()
29
30 get(key: string, make: () => Promise<T>): Promise<T> {
31 let entry = this.#entries.get(key)
32 if (entry === undefined) {
33 entry = make()
34 this.#entries.set(key, entry)
35 }
36 return entry
37 }
38}
39hooks/pipeline/index.ts 123 lines1import type { FoundImage } from '../detect/tool-output.ts'
2import type { Io } from '../io.ts'
3import type { Registry } from '../renderers/index.ts'
4import type { Artifact, CodeRequest, Renderer } from '../renderers/types.ts'
5import { Memo, cacheDir, cacheKey } from './cache.ts'
6import { type Exec, MissingCommand, execWith, failure } from './exec.ts'
7import { readPngHeader, readPngSize } from './png.ts'
8import { convertToPng, rasterizeSvg } from './raster.ts'
9
10/** A PNG on disk, with its size at 1x. */
11export type Rendered = { path: string; width: number; height: number }
12export type Outcome = Rendered | { error: string } | { skipped: true }
13
14const settle = (pending: Promise<Outcome>): Promise<Outcome> =>
15 pending.catch((error: unknown) => ({ error: error instanceof Error ? error.message : String(error) }))
16
17export class Pipeline {
18 readonly #registry: Registry
19 readonly #code = new Memo<Outcome>()
20 readonly #images = new Memo<Outcome>()
21 #canRasterizeSvg = true
22 #dir: Promise<string> | undefined
23
24 constructor(registry: Registry) {
25 this.#registry = registry
26 }
27
28 code(io: Io, request: CodeRequest): Promise<Outcome> {
29 const key = cacheKey(request)
30 return this.#code.get(key, () => settle(this.#renderCode(io, request, key)))
31 }
32
33 image(io: Io, image: FoundImage): Promise<Outcome> {
34 return this.#images.get(image.base64, () => settle(this.#materialize(io, image)))
35 }
36
37 async #renderCode(io: Io, request: CodeRequest, key: string): Promise<Outcome> {
38 const dir = await this.#cacheDir(io)
39 const pathAt = (zoom: number) => `${dir}/${key}@${zoom}x.png`
40 for (const zoom of new Set([Math.max(1, request.scale), 1])) {
41 if (await io.exists(pathAt(zoom))) return this.#rendered(io, pathAt(zoom), zoom)
42 }
43
44 // A missing renderer hands the block to the next, and so does a failing
45 // one, since mmdc runs mermaid.js itself and accepts what mmdr rejects.
46 // The error shown is the first, from the renderer preferred. With none
47 // installed the block stays code, as it reads well enough as text.
48 const exec = execWith(io)
49 let firstError: { error: string } | undefined
50 for (const renderer of this.#registry.chain(request.lang)) {
51 try {
52 const outcome = await this.#tryRenderer(io, exec, renderer, request, dir, pathAt)
53 if (!('error' in outcome)) return outcome
54 firstError ??= outcome
55 } catch (error) {
56 if (!(error instanceof MissingCommand)) throw error
57 }
58 }
59 return firstError ?? { skipped: true }
60 }
61
62 async #tryRenderer(
63 io: Io,
64 exec: Exec,
65 renderer: Renderer,
66 request: CodeRequest,
67 dir: string,
68 pathAt: (zoom: number) => string,
69 ): Promise<Outcome> {
70 const ctx = { io, exec, dir, outPath: pathAt, canRasterizeSvg: this.#canRasterizeSvg }
71 const artifact: Artifact | { error: string } = await renderer.render(request, ctx)
72 if ('error' in artifact) return artifact
73 if (artifact.kind === 'png') return this.#rendered(io, artifact.path, artifact.zoom)
74
75 const zoom = Math.max(1, request.scale)
76 try {
77 const failed = await rasterizeSvg(exec, artifact.svg, pathAt(zoom), zoom)
78 return failed ?? this.#rendered(io, pathAt(zoom), zoom)
79 } catch (error) {
80 if (!(error instanceof MissingCommand) || !this.#canRasterizeSvg) throw error
81 // The renderer may have a way without resvg (mmdr writes PNG itself).
82 this.#canRasterizeSvg = false
83 return this.#tryRenderer(io, exec, renderer, request, dir, pathAt)
84 }
85 }
86
87 // resvg, mmdr and base64 -d do not create the directory they write to;
88 // io.write does.
89 #cacheDir(io: Io): Promise<string> {
90 this.#dir ??= cacheDir(io).then(async (dir) => {
91 await io.write(`${dir}/.keep`, '')
92 return dir
93 })
94 return this.#dir
95 }
96
97 async #rendered(io: Io, path: string, zoom: number): Promise<Rendered> {
98 const size = await readPngSize(io, path)
99 return { path, width: size.width / zoom, height: size.height / zoom }
100 }
101
102 // io.write takes text only, so the picture's bytes reach disk through
103 // base64 -d.
104 async #materialize(io: Io, image: FoundImage): Promise<Outcome> {
105 const exec = execWith(io)
106 const dir = await this.#cacheDir(io)
107 const key = cacheKey(image.base64)
108 const path = `${dir}/img-${key}.png`
109 if (!(await io.exists(path))) {
110 const isPng = image.mime === 'image/png'
111 const raw = isPng ? path : `${dir}/img-${key}.${image.mime.slice('image/'.length)}`
112 const decoded = await exec(['sh', '-c', 'base64 -d > "$1"', 'sh', raw], image.base64)
113 if (decoded.exitCode !== 0) return failure('base64', decoded)
114 if (!isPng) {
115 const failed = await convertToPng(exec, raw, path)
116 await exec(['rm', '-f', raw]).catch(() => undefined)
117 if (failed) return failed
118 }
119 }
120 return { path, ...(await readPngHeader(exec, path)) }
121 }
122}
123hooks/renderers/index.ts 42 lines1import type { Catalog } from '../detect/markdown.ts'
2import { d2 } from './d2.ts'
3import { graphviz } from './graphviz.ts'
4import { mathjax } from './mathjax.ts'
5import { mmdc } from './mmdc.ts'
6import { mmdr } from './mmdr.ts'
7import { svg } from './svg.ts'
8import type { Renderer } from './types.ts'
9
10/** Every renderer, each lang's in default preference order. */
11export const RENDERERS: readonly Renderer[] = [mmdr, mmdc, graphviz, d2, svg, mathjax]
12
13export type Registry = Catalog & {
14 chain(lang: string): readonly Renderer[]
15 /** Ids in the `renderers` option that no renderer has. */
16 unknown: readonly string[]
17}
18
19// A lang with any of its renderers in `order` uses only those, in that order;
20// the others keep the default order. Ids are unique across langs, so the
21// option needs no lang prefix.
22export function createRegistry(order: readonly string[], all: readonly Renderer[] = RENDERERS): Registry {
23 const langOfTag = new Map<string, string>()
24 const chains = new Map<string, Renderer[]>()
25 for (const renderer of all) {
26 const [lang] = renderer.langs
27 for (const tag of renderer.langs) langOfTag.set(tag, lang)
28 chains.set(lang, [...(chains.get(lang) ?? []), renderer])
29 }
30 for (const [lang, chain] of chains) {
31 const chosen = order.flatMap((id) => chain.filter((r) => r.id === id))
32 if (chosen.length > 0) chains.set(lang, chosen)
33 }
34 const ids = new Set(all.map((r) => r.id))
35 return {
36 langOf: (tag) => langOfTag.get(tag),
37 accepts: (lang, source) => (chains.get(lang) ?? []).some((r) => r.accepts?.(source) ?? true),
38 chain: (lang) => chains.get(lang) ?? [],
39 unknown: order.filter((id) => !ids.has(id)),
40 }
41}
42hooks/ui/inline.tsx 58 lines1import { type Figures, type ToolCall, blocksOf, renderBlocks, renderImages } from '../figures.ts'
2import type { Io } from '../io.ts'
3import type { Viewport } from '../layout.ts'
4import type { Outcome } from '../pipeline/index.ts'
5import { type TerminalElements, pictures } from './pictures.tsx'
6import { fullSize, thumbnailSize } from './placements.ts'
7
8// Each returns the pictures to draw under the engine's own row, or undefined
9// to leave the row alone.
10
11export async function replyPictures(io: Io, figures: Figures, el: TerminalElements, text: string, viewport: Viewport) {
12 const blocks = blocksOf(figures, text)
13 if (blocks.length === 0) return undefined
14 const outcomes = await renderBlocks(io, figures, blocks)
15 return pictures(
16 el,
17 outcomes,
18 fullSize(figures.config),
19 viewport,
20 blocks.map((b) => b.lang),
21 )
22}
23
24export async function toolUsePictures(
25 io: Io,
26 figures: Figures,
27 el: TerminalElements,
28 call: ToolCall,
29 viewport: Viewport,
30) {
31 if (!figures.config.toolImages) return undefined
32 const outcomes = await renderImages(io, figures, call)
33 if (outcomes.length === 0) return undefined
34 return pictures(el, outcomes, fullSize(figures.config), viewport, call.tool)
35}
36
37// A collapsed group draws one count line and no ToolUse rows, so its
38// pictures are drawn under that line; an expanded one reaches ToolUse.
39export async function toolGroupPictures(
40 io: Io,
41 figures: Figures,
42 el: TerminalElements,
43 calls: readonly { tool_use_id?: string; tool: string; isRunning: boolean; output?: unknown }[],
44 viewport: Viewport,
45) {
46 if (!figures.config.toolImages) return undefined
47 const perCall = await Promise.all(
48 calls.map((call) =>
49 call.tool_use_id === undefined || call.isRunning || call.output === undefined
50 ? Promise.resolve([] as Outcome[])
51 : renderImages(io, figures, { tool_use_id: call.tool_use_id, tool: call.tool, output: call.output }),
52 ),
53 )
54 const outcomes = perCall.flat()
55 if (outcomes.length === 0) return undefined
56 return pictures(el, outcomes, thumbnailSize(figures.config), viewport, 'image')
57}
58hooks/ui/pane.tsx 125 lines1import type { Elements } from 'claude-code'
2
3import type { GalleryItem } from '../../types'
4import { type Figures, blocksOf, renderBlocks, renderImages } from '../figures.ts'
5import type { Io } from '../io.ts'
6import { LEGIBLE_SCALE, pane, type Viewport } from '../layout.ts'
7import { MissingCommand, execWith } from '../pipeline/exec.ts'
8import type { Outcome } from '../pipeline/index.ts'
9import { fullSize, thumbnailSize } from './placements.ts'
10
11export const PANE = 'figures'
12const GALLERY_SIZE = 30
13
14/** The pane's chosen picture, held in $.state by register.tsx. */
15export type Selection = { index: number | null; set(index: number): void }
16
17export type PaneProps = {
18 bodyColumns: number
19 placement: string
20 scroll: { bodyRows: number }
21}
22
23// Render hooks may not write state, so the gallery is rebuilt from the
24// transcript; the memos make that a lookup for anything drawn.
25async function collect(io: Io, figures: Figures): Promise<GalleryItem[]> {
26 const items = (outcomes: Outcome[], kind: GalleryItem['kind'], label: (i: number) => string): GalleryItem[] =>
27 outcomes.flatMap((outcome, i) =>
28 'path' in outcome
29 ? [{ path: outcome.path, width: outcome.width, height: outcome.height, label: label(i), kind }]
30 : [],
31 )
32 const perMessage = await Promise.all(
33 (await io.messages()).map(async (message) => {
34 if (message.role !== 'assistant') return []
35 const blocks = blocksOf(figures, message.text)
36 const diagrams =
37 blocks.length > 0
38 ? items(await renderBlocks(io, figures, blocks), 'diagram', (i) => blocks[i]?.lang ?? 'diagram')
39 : []
40 if (!figures.config.toolImages) return diagrams
41 const tools = await Promise.all(
42 message.toolUses
43 .filter((use) => use.result !== undefined && !use.isError)
44 .map(async (use) =>
45 items(
46 await renderImages(io, figures, { tool_use_id: use.tool_use_id, tool: use.tool, output: use.result }),
47 'tool',
48 () => use.tool,
49 ),
50 ),
51 )
52 return [...diagrams, ...tools.flat()]
53 }),
54 )
55 const seen = new Set<string>()
56 return perMessage
57 .flat()
58 .filter((item) => !seen.has(item.path) && seen.add(item.path))
59 .slice(-GALLERY_SIZE)
60}
61
62async function openExternally(io: Io, path: string): Promise<void> {
63 const exec = execWith(io)
64 for (const opener of ['open', 'xdg-open']) {
65 try {
66 await exec([opener, path])
67 return
68 } catch (error) {
69 if (!(error instanceof MissingCommand)) throw error
70 }
71 }
72 io.toast('Neither open nor xdg-open is on PATH')
73}
74
75type PaneElements = Pick<Elements['terminal'], 'Box' | 'Button' | 'Image' | 'Text'>
76
77export async function drawPane(
78 io: Io,
79 figures: Figures,
80 { Box, Button, Image, Text }: PaneElements,
81 props: PaneProps,
82 viewport: Viewport,
83 selection: Selection,
84) {
85 const { config } = figures
86 const list = await collect(io, figures)
87 if (list.length === 0) return <Text dimColor>No diagrams or images in this session yet.</Text>
88
89 // Opened without a choice, the pane shows the newest picture the
90 // transcript could not draw legibly, which is what /figures is for.
91 const illegible = list.findLastIndex(
92 (item) =>
93 (item.kind === 'tool' ? thumbnailSize(config) : fullSize(config)).fit(item, viewport).scale < LEGIBLE_SCALE,
94 )
95 const fallback = illegible === -1 ? list.length - 1 : illegible
96 const index = selection.index === null ? fallback : Math.min(Math.max(selection.index, 0), list.length - 1)
97 const item = list[index]!
98
99 // An inline pane grows to its content, so its bodyRows is the height drawn
100 // last rather than room to fill, and the layout clips what does not fit.
101 // Only the dock's is fixed; inline, the prompt and its chrome (about 14
102 // rows with the frame) stay below. One row goes to the header.
103 const bodyRows = props.placement === 'dock' ? Math.max(1, props.scroll.bodyRows - 1) : Math.max(4, viewport.rows - 14)
104 const { columns, rows } = pane(item, config, { columns: props.bodyColumns, rows: bodyRows })
105 return (
106 <Box flexDirection="column">
107 <Box>
108 <Button key="prev" label="Prev" hotkey="p" onPress={() => selection.set(Math.max(0, index - 1))} />
109 <Button
110 key="next"
111 label="Next"
112 hotkey="n"
113 onPress={() => selection.set(Math.min(list.length - 1, index + 1))}
114 />
115 <Button key="open" label="Open" hotkey="o" onPress={() => openExternally(io, item.path)} />
116 <Text dimColor>
117 {' '}
118 {index + 1}/{list.length} {item.label}
119 </Text>
120 </Box>
121 <Image source={{ file: item.path, format: 'png' }} columns={columns} rows={rows} alt={`[${item.label}]`} />
122 </Box>
123 )
124}
125hooks/ui/pictures.tsx 59 lines1import type { Elements } from 'claude-code'
2
3import { type Fit, LEGIBLE_SCALE, type Size, type Viewport } from '../layout.ts'
4import type { Outcome } from '../pipeline/index.ts'
5
6export type TerminalElements = Pick<Elements['terminal'], 'Box' | 'Image' | 'Text'>
7
8export type Placement = {
9 fit(natural: Size, viewport: Viewport): Fit
10 /** Shown under a picture drawn below LEGIBLE_SCALE. */
11 enlarge: string
12}
13
14export const INDENT = 2
15
16export function pictures(
17 { Box, Image, Text }: TerminalElements,
18 outcomes: Outcome[],
19 placement: Placement,
20 viewport: Viewport,
21 labels: string | readonly string[],
22) {
23 const room = { columns: viewport.columns - INDENT, rows: viewport.rows }
24 return outcomes.map((outcome, i) => {
25 const label = typeof labels === 'string' ? labels : (labels[i] ?? 'image')
26 if ('skipped' in outcome) return null
27 if ('error' in outcome) {
28 return (
29 <Box key={`${label}-${i}`} marginLeft={INDENT}>
30 <Text color="red">
31 {label}: {outcome.error.split('\n')[0]}
32 </Text>
33 </Box>
34 )
35 }
36 const { columns, rows, scale } = placement.fit(outcome, room)
37 return (
38 <Box key={`${label}-${i}`} marginLeft={INDENT} marginTop={1} flexDirection="column">
39 <Image
40 source={{ file: outcome.path, format: 'png' }}
41 columns={columns}
42 rows={rows}
43 alt={`[${label} ${i + 1}]`}
44 />
45 {scale < LEGIBLE_SCALE ? (
46 <Text dimColor>
47 shown at {Math.round(scale * 100)}% · {placement.enlarge}
48 </Text>
49 ) : null}
50 </Box>
51 )
52 })
53}
54
55export const viewportOf = (e: { viewport?: { columns: number; rows: number } }): Viewport => ({
56 columns: e.viewport?.columns ?? 80,
57 rows: e.viewport?.rows ?? 40,
58})
59hooks/detect/markdown.ts 35 lines1export type Block = { lang: string; source: string }
2
3/** What the renderers know: which fence tags they draw, and which sources they take. */
4export type Catalog = {
5 langOf(tag: string): string | undefined
6 accepts(lang: string, source: string): boolean
7}
8
9const FENCE = /^```([^\n`]*)\n([\s\S]*?)^```[ \t]*$/gm
10const DISPLAY_MATH = /\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]/g
11
12// Display math is looked for only between fences, so a `$$` in a shell
13// snippet is left alone; inline `$x$` is not drawn, since a picture cannot
14// sit inside a line of text.
15export function blocksIn(text: string, catalog: Catalog): Block[] {
16 const found: Block[] = []
17 const add = (lang: string | undefined, source: string) => {
18 if (lang !== undefined && catalog.accepts(lang, source)) found.push({ lang, source })
19 }
20 const prose = (from: number, to: number) => {
21 for (const m of text.slice(from, to).matchAll(DISPLAY_MATH)) {
22 const source = (m[1] ?? m[2] ?? '').trim()
23 if (source) add(catalog.langOf('math'), source)
24 }
25 }
26 let end = 0
27 for (const m of text.matchAll(FENCE)) {
28 prose(end, m.index)
29 end = m.index + m[0].length
30 add(catalog.langOf(m[1]?.trim().toLowerCase() ?? ''), m[2] ?? '')
31 }
32 prose(end, text.length)
33 return found
34}
35hooks/detect/tool-output.ts 75 lines1export type FoundImage = { base64: string; mime: string }
2
3const IMAGE_MIME = /^image\/(png|jpeg|gif|webp)$/
4export const SVG_MIME = 'image/svg+xml'
5
6// Tool results carry pictures in a few shapes: Read's `{ type: 'image', file:
7// { base64, type } }`, MCP content blocks `{ type: 'image', data, mimeType }`,
8// and API blocks `{ type: 'image', source: { data, media_type } }`.
9export function imagesIn(output: unknown, limit = 4): FoundImage[] {
10 const found: FoundImage[] = []
11 const visit = (value: unknown, depth: number) => {
12 if (found.length >= limit || depth > 6 || value === null || typeof value !== 'object') return
13 if (Array.isArray(value)) {
14 for (const item of value) visit(item, depth + 1)
15 return
16 }
17 const record = value as Record<string, unknown>
18 if (record.type === 'image') {
19 const file = record.file as Record<string, unknown> | undefined
20 const source = record.source as Record<string, unknown> | undefined
21 const base64 = file?.base64 ?? record.data ?? source?.data
22 const mime = file?.type ?? record.mimeType ?? source?.media_type
23 if (typeof base64 === 'string' && typeof mime === 'string' && IMAGE_MIME.test(mime)) {
24 found.push({ base64, mime })
25 return
26 }
27 }
28 for (const child of Object.values(record)) visit(child, depth + 1)
29 }
30 visit(output, 0)
31 return found
32}
33
34export type SentPath = { path: string; mime: string }
35
36const MIME_BY_EXTENSION: Record<string, string> = {
37 png: 'image/png',
38 jpg: 'image/jpeg',
39 jpeg: 'image/jpeg',
40 gif: 'image/gif',
41 webp: 'image/webp',
42 svg: SVG_MIME,
43}
44
45// SendUserFile returns where its files are, `{ attachments: [{ path, isImage,
46// media_type }] }`, not their bytes. `isImage` is false for an SVG, which the
47// svg renderer still draws, so the media type decides.
48export function sentFilesIn(tool: string, output: unknown, limit = 4): SentPath[] {
49 if (tool !== 'SendUserFile' || output === null || typeof output !== 'object') return []
50 const attachments = (output as { attachments?: unknown }).attachments
51 if (!Array.isArray(attachments)) return []
52 const found: SentPath[] = []
53 for (const attachment of attachments) {
54 if (found.length >= limit) break
55 if (attachment === null || typeof attachment !== 'object') continue
56 const { path, media_type } = attachment as Record<string, unknown>
57 if (typeof path !== 'string') continue
58 const mime =
59 typeof media_type === 'string' ? media_type : MIME_BY_EXTENSION[path.split('.').pop()?.toLowerCase() ?? '']
60 if (mime !== undefined && (IMAGE_MIME.test(mime) || mime === SVG_MIME)) found.push({ path, mime })
61 }
62 return found
63}
64
65// Read returns an SVG as text, `{ type: 'text', file: { filePath, content,
66// startLine, numLines, totalLines } }`. A partial read is not a picture.
67export function readSvgIn(tool: string, output: unknown): string | undefined {
68 if (tool !== 'Read' || output === null || typeof output !== 'object') return undefined
69 const { type, file } = output as { type?: unknown; file?: Record<string, unknown> }
70 if (type !== 'text' || file === undefined || typeof file.filePath !== 'string') return undefined
71 if (!file.filePath.toLowerCase().endsWith('.svg') || typeof file.content !== 'string') return undefined
72 const isWhole = file.startLine === 1 && file.numLines === file.totalLines && file.truncatedByTokenCap !== true
73 return isWhole ? file.content : undefined
74}
75