Draws Mermaid, PlantUML and HTML-widget fences in Claude's replies as pictures inline in the terminal transcript, with Unicode box art as the fallback

A Claude Code mod that draws ``` `mermaid ``, `` `plantuml `` and `` `widget ``` fences in Claude's replies as pictures, inline in the terminal transcript, where the code block would otherwise be. Where the terminal cannot draw pictures, the diagram is shown as Unicode box art.
Tested only with the Rio terminal on Windows, connected over SSH to Claude Code 2.1.309 running on Linux x64 (Ubuntu 24.04), with fullscreen rendering (/tui fullscreen). Other terminals, and Claude Code running directly on Windows or macOS, are untested.
Pictures need a terminal that supports the kitty graphics protocol with Unicode placeholders. In any other terminal, diagrams show as Unicode box art.
render/server.mjs keeps running between diagrams. It uses the theme variables and settings claude.ai uses for Markdown diagrams (htmlLabels: false, the classic look). The first picture takes about a second, because the browser starts; later ones take under half a second. The box-art form comes from beautiful-mermaid, which also draws the picture when no browser is installed.smetana layout engine, so Graphviz is not needed. `widget `` fence holds a whole HTML document, for laid-out panels that are not graphs, such as a comparison card or a status board. It is laid out at a 760 px CSS width and screenshotted in the same Chromium, with prefers-color-scheme set from the /diagram theme. The fence is deliberately not `` `html ```, so ordinary HTML examples in a reply still print as source.This mod has been tested in Rio, which needs CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1, because Claude Code doesn't detect it as able to draw images. Set the same variable for any other terminal that draws kitty graphics protocol images with Unicode placeholders. According to the Claude Code docs, the mod Image element also draws pictures without it in kitty 0.28 or later, Ghostty, and iTerm2 3.5 or later (iTerm2 needs fullscreen rendering). Those terminals are untested with this mod.
Inside tmux or screen no picture is drawn. Leave CLAUDE_CODE_FORCE_TERMINAL_IMAGES unset there: with it set, Claude Code sends the picture, the multiplexer drops it, and the transcript shows blank rows instead of the box art.
In every other terminal, and after /diagram text, the diagram is Unicode box art. Box art exists for Mermaid flowchart, sequence, class, state, ER and XY chart diagrams, and for PlantUML sequence diagrams. Other diagram types show a one-line note in a terminal without pictures, and their source in text mode. Widgets have no box-art form.
PATH. puppeteer-core 25 and mermaid 12 require it.render.mjs uses $JAVA_HOME/bin/java, then ~/.local/share/jdk-17/bin/java, then java on PATH.render/install-browser.sh installs. server.mjs finds the Linux x64 build on its own. On other platforms, set CLAUDE_DIAGRAMS_BROWSER to the chrome-headless-shell binary; that path is untested. Without a browser, Mermaid pictures come from beautiful-mermaid, which lays out the six types listed above, and widgets do not render.From a clone of this repository:
cd eashans-claude-mods/tui-diagrams/render
npm install
./fetch-plantuml.sh # PlantUML 1.2026.9, MIT build, SHA-256 checked, saved as render/plantuml-mit.jar
./install-browser.sh # chrome-headless-shell, about 270 MB, into ~/.cache/claude-diagrams; no root needed
Then load the mod. For one session:
claude --plugin-dir /path/to/eashans-claude-mods/tui-diagrams
For every session, set CLAUDE_CODE_PLUGIN_DIRS to the mod's absolute path, either in your shell or in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/eashans-claude-mods/tui-diagrams"
}
}
Separate several directories with :, or ; on Windows. Once the mod has loaded, /plugin lists it on the Installed tab. Run /diagram test to check whether your terminal draws the pictures.
Paste the section in CLAUDE-snippet.md into ~/.claude/CLAUDE.md. It tells the model that these fences become pictures, so that it writes the fence instead of inspecting the mod. It also covers which diagram types render, the shape that fits under the mod's 40-row cap, the syntax rules that matter, and when to draw a diagram unprompted.
The mod does not put this into the system prompt itself. Where Claude Code's built-in sec-default guard loads (on machines with managed settings, and with Team or Enterprise logins), it skips the prompt hooks of every user-installed mod. CLAUDE.md is read in every session.
| Command | Effect |
|---|---|
/diagram | Show the current mode and theme |
/diagram image / /diagram text | Pictures, or box art everywhere |
/diagram dark / /diagram light | Picture theme |
/diagram test | Open a pane with a test picture and a rendered Mermaid sample |
In the /diagram test pane, dim alt text in place of a picture means Claude Code decided this terminal does not draw images. Blank rows mean Claude Code sent the picture and something between it and the terminal dropped it: tmux, screen, or ConPTY on Windows.
The mode and theme persist across sessions, in the JSON file Claude Code keeps for each mod under ~/.claude/plugins/store/.
render/render.mjs reads diagram source on stdin and writes one JSON line on stdout:
node render/render.mjs --kind mermaid --format png --width 1600 --theme dark < diagram.mmd
node render/render.mjs --kind plantuml --format both --theme light < diagram.puml
node render/render.mjs --kind widget --format png --width 760 < panel.html
A picture comes back as {"ok":true,"png":"<base64>","width":W,"height":H}, plus "text" with --format both where box art exists. A picture drawn in the browser also carries "renderer": mermaid.js or html. --format text returns only {"ok":true,"text":"..."}. A failure is {"ok":false,"error":"..."} with exit code 1. --no-browser makes Mermaid use beautiful-mermaid instead of mermaid.js.
--width sets the CSS width of a widget (default 1600; the mod passes 760) and the PNG width on the beautiful-mermaid path. mermaid.js and PlantUML size the picture from the diagram itself.
The first Mermaid or widget render starts render/server.mjs in the background. It listens on a Unix socket, $XDG_RUNTIME_DIR/claude-diagrams-<uid>.sock (or under /tmp when XDG_RUNTIME_DIR is unset). It exits after 15 minutes without a request, or when the socket file is removed.
render/kitty-probe.py tests a terminal without Claude Code in the loop. Run it inside the terminal under test, on the machine that runs Claude Code, with python3 render/kitty-probe.py from this folder. It draws a test PNG with classic placement and with Unicode placeholders (the form Claude Code uses), then sends a query the terminal should answer. If nothing draws and no reply arrives, something between the terminal and the shell is stripping the escape sequences.render/render.mjs by hand to see the renderer's full message.--no-sandbox, because Ubuntu 24.04 restricts the unprivileged user namespaces its sandbox needs.securityLevel: 'strict'.| Path | Contents |
|---|---|
.claude-plugin/plugin.json | The plugin manifest |
hooks/hooks.json, hooks/register.tsx | The hooks module: the reply render hook, the /diagram command and the test pane |
types/index.d.ts | The mod's $.state values |
render/render.mjs | The renderer CLI |
render/server.mjs, render/mermaid-page.html | The warm headless Chromium with mermaid.js loaded; it also renders widgets |
render/fetch-plantuml.sh | Downloads and verifies the PlantUML jar |
render/install-browser.sh | Installs chrome-headless-shell |
render/kitty-probe.py | The terminal probe |
assets/test.png | The test picture for /diagram test and the probe |
CLAUDE-snippet.md | The section to paste into ~/.claude/CLAUDE.md |
Claude Code writes its type declarations into .claude-plugin/types/ the first time it loads the mod from a folder in an interactive session. That folder is git-ignored. tsconfig.json extends the declarations, so after that first load npx -p typescript tsc -p . type-checks the hooks module. claude plugin validate . checks it without a session.
MIT. See LICENSE.
hooks/register.tsx 298 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DiagramMode, DiagramSettings, DiagramTheme } from '../types'
5
6// Draws ```mermaid, ```plantuml and ```widget fences in Claude's replies as
7// pictures (Claude Code's Image element: kitty, Ghostty, iTerm2, or a terminal
8// forced with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1) or as Unicode box art (any
9// terminal), in place, in the transcript. Rendering happens in render/render.mjs
10// on the host; the hook only ships source in and reads a JSON line out.
11
12const TEST_PANE = 'diagrams-test'
13const DEFAULTS: DiagramSettings = { mode: 'image', theme: 'dark' }
14
15// The model is NOT told about this mod from here. Where Claude Code's built-in
16// `sec-default` guard loads (machines with managed settings, and Team or
17// Enterprise logins), it skips every user-installed mod's prompt hooks, so a
18// `prompt.compose` hook would reach the model only some of the time. The
19// guidance (fences become pictures; which types render; the shape that fits;
20// when to draw one unprompted) lives in CLAUDE-snippet.md, pasted into the
21// person's ~/.claude/CLAUDE.md, which every session reads. Without it the
22// model tends to inspect the mod before writing a fence (one measured case:
23// almost three minutes).
24
25// Settings live in $.state so every drawing re-runs when they change, and in
26// $.store so they survive the session. `generation` is bumped when a render
27// lands, which redraws every drawing that read it while waiting.
28// `plugin` must equal the manifest's `name`: only a value's owner may write it.
29const settings = atom({ plugin: 'tui-diagrams', key: 'settings' } as const, DEFAULTS)
30const generation = atom({ plugin: 'tui-diagrams', key: 'generation' } as const, 0)
31
32type Kind = 'mermaid' | 'plantuml' | 'widget'
33type Block = { type: 'md'; text: string } | { type: 'diagram'; kind: Kind; source: string }
34type Rendered =
35 | { ok: true; png?: string; width?: number; height?: number; text?: string }
36 | { ok: false; error: string }
37type Slot = { status: 'pending' } | { status: 'done'; result: Rendered }
38
39// One fence: ```mermaid / ```plantuml / ```puml / ```widget, anything after the
40// language ignored, closed by a ``` on its own line. CRLF tolerated.
41//
42// `widget` is a whole HTML document drawn as a picture. It is deliberately NOT
43// spelled ```html: that fence has to keep meaning "here is some HTML source",
44// or every code example in a reply would turn into a screenshot.
45const FENCE = /```[ \t]*(mermaid|plantuml|puml|widget)\b[^\n]*\r?\n([\s\S]*?)\r?\n[ \t]*```/g
46
47function splitFences(text: string): Block[] {
48 const blocks: Block[] = []
49 let last = 0
50 for (const match of text.matchAll(FENCE)) {
51 const at = match.index ?? 0
52 if (at > last) blocks.push({ type: 'md', text: text.slice(last, at) })
53 const kind: Kind = match[1] === 'mermaid' ? 'mermaid' : match[1] === 'widget' ? 'widget' : 'plantuml'
54 let source = (match[2] ?? '').trim()
55 if (kind === 'plantuml' && !/@start\w+/.test(source)) source = `@startuml\n${source}\n@enduml`
56 blocks.push({ type: 'diagram', kind, source })
57 last = at + match[0].length
58 }
59 if (last < text.length) blocks.push({ type: 'md', text: text.slice(last) })
60 return blocks
61}
62
63// There is no "widget" language for the highlighter; a widget is HTML.
64function codeLanguage(kind: Kind): string {
65 return kind === 'widget' ? 'html' : kind
66}
67
68// FNV-1a, enough to key a render cache for one session.
69function hashOf(text: string): string {
70 let h = 0x811c9dc5
71 for (let i = 0; i < text.length; i++) {
72 h ^= text.charCodeAt(i)
73 h = Math.imul(h, 0x01000193) >>> 0
74 }
75 return h.toString(16).padStart(8, '0')
76}
77
78// A terminal cell is about twice as tall as it is wide; the picture is scaled
79// to fill the cell box, so the box must keep the picture's aspect ratio.
80const CELL_ASPECT = 0.5
81const MAX_ROWS = 40
82
83function cellBox(width: number, height: number, maxColumns: number): { columns: number; rows: number } {
84 const aspect = width / Math.max(1, height)
85 // Never upscale past ~8 px per cell, or a small PlantUML picture goes blurry.
86 let columns = Math.max(8, Math.min(maxColumns, Math.ceil(width / 8), 255))
87 let rows = Math.round((columns * CELL_ASPECT) / aspect)
88 if (rows > MAX_ROWS) {
89 rows = MAX_ROWS
90 columns = Math.round((rows * aspect) / CELL_ASPECT)
91 }
92 return { columns: Math.max(1, Math.min(255, columns)), rows: Math.max(1, Math.min(255, rows)) }
93}
94
95// Reads stored settings back, ignoring fields an older version may have written.
96function settingsFrom(value: unknown): DiagramSettings | undefined {
97 if (typeof value !== 'object' || value === null) return undefined
98 const v = value as Record<string, unknown>
99 if (!(v.mode === 'image' || v.mode === 'text') || !(v.theme === 'dark' || v.theme === 'light')) return undefined
100 return { mode: v.mode, theme: v.theme }
101}
102
103// Renders done or in flight this session, by kind + theme + source. A module
104// variable, so a hot reload starts it empty; that costs one re-render per
105// diagram on screen, nothing else.
106const renders = new Map<string, Slot>()
107
108async function runRenderer($: EngineInterface, kind: Kind, source: string, theme: DiagramTheme): Promise<Rendered> {
109 try {
110 // A widget is HTML laid out at a CSS width, so 1600 would be a wall of
111 // whitespace; and it has no box-art form, so png only.
112 const argv = kind === 'widget'
113 ? ['--kind', 'widget', '--format', 'png', '--width', '760', '--theme', theme]
114 : ['--kind', kind, '--format', 'both', '--width', '1600', '--theme', theme]
115 const run = await $.process.run(
116 ['node', `${$.plugin.root}/render/render.mjs`, ...argv],
117 { stdin: source, timeoutMs: 90_000 },
118 )
119 const line = run.stdout.trim().split('\n').pop() ?? ''
120 try {
121 return JSON.parse(line) as Rendered
122 } catch {
123 return { ok: false, error: (run.stderr || run.stdout || `renderer exited ${run.exitCode}`).trim().slice(0, 500) }
124 }
125 } catch (error) {
126 return { ok: false, error: error instanceof Error ? error.message : String(error) }
127 }
128}
129
130// Answers the render when it is done. Otherwise starts it, once, on a timer:
131// a timer runs as a dispatch of its own, so a redraw that abandons the drawing
132// which asked for it (the pane taking focus, the reply still streaming) does
133// not abort the renderer. When it lands, `generation` is bumped and every
134// drawing that read it is drawn again.
135function renderOf($: EngineInterface, kind: Kind, source: string, theme: DiagramTheme): Rendered | undefined {
136 const key = `${kind}|${theme}|${hashOf(source)}`
137 const slot = renders.get(key)
138 if (slot?.status === 'done') return slot.result
139 if (slot) return undefined
140 renders.set(key, { status: 'pending' })
141 $.clock.after(0, () => {
142 void (async () => {
143 const result = await runRenderer($, kind, source, theme)
144 // An abort is the engine's doing, not the diagram's: forget it so the
145 // next drawing asks again. A renderer error stays, or every redraw
146 // would run it again.
147 if (!result.ok && /abort/i.test(result.error)) renders.delete(key)
148 else renders.set(key, { status: 'done', result })
149 await update($, generation, n => (n ?? 0) + 1)
150 })()
151 })
152 return undefined
153}
154
155export const register: Register = on => {
156 on('session.start', async ($, e, next) => {
157 await $.command.register({
158 name: 'diagram',
159 description: 'Diagrams in the transcript: image or text mode, theme, and a terminal test',
160 argumentHint: '[image|text|dark|light|test]',
161 })
162 const stored = settingsFrom(await $.store.get('settings'))
163 if (stored) await update($, settings, () => stored)
164 return next(e)
165 })
166
167 on('command.run', { command: 'diagram' }, async ($, e) => {
168 const arg = e.args.trim().toLowerCase()
169 const current = await read($, settings)
170
171 if (arg === 'test') {
172 const opened = await $.ui.open({ id: TEST_PANE, title: 'Diagram test', focus: true })
173 return {
174 text:
175 `Diagram test pane opened (placed=${opened.isPlaced}, fullscreen=${e.presentation.isFullscreen}, ` +
176 `columns=${e.presentation.columns}). Dim alt text in place of a picture means Claude Code decided ` +
177 `this terminal does not draw images; blank rows mean it sent the picture and the terminal lost it ` +
178 `(on Windows: ConPTY). render/kitty-probe.py tests the terminal without Claude Code in the loop.`,
179 }
180 }
181
182 let next: DiagramSettings | undefined
183 if (arg === 'image' || arg === 'text') next = { ...current, mode: arg as DiagramMode }
184 if (arg === 'dark' || arg === 'light') next = { ...current, theme: arg as DiagramTheme }
185 if (next) {
186 await $.store.set('settings', next)
187 await update($, settings, () => next)
188 return { text: `Diagrams: mode=${next.mode}, theme=${next.theme} (saved).` }
189 }
190
191 return {
192 text:
193 `Diagrams: mode=${current.mode}, theme=${current.theme}\n` +
194 `/diagram image pictures where the terminal draws them (kitty, Ghostty, iTerm2, or CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1); elsewhere the box art shows as alt text\n` +
195 `/diagram text Unicode box art in every terminal\n` +
196 `/diagram dark|light picture theme\n` +
197 `/diagram test open a pane with a test picture\n` +
198 `The model learns to use fences from CLAUDE-snippet.md in the mod folder, pasted into ~/.claude/CLAUDE.md.`,
199 }
200 })
201
202 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
203 if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
204 const blocks = splitFences(e.props.text)
205 if (!blocks.some(b => b.type === 'diagram')) return next(e)
206
207 await read($, generation)
208 const { mode, theme } = await read($, settings)
209 const { Box, Text, Markdown, Code, Image } = $.ui.resolve(e)
210 const gutter = 2
211 const maxColumns = Math.max(20, (e.viewport?.columns ?? 80) - gutter - 1)
212
213 const parts: ReturnType<typeof Box>[] = []
214 for (const block of blocks) {
215 if (block.type === 'md') {
216 if (block.text.trim()) parts.push(<Box><Markdown text={block.text.replace(/^\n+|\n+$/g, '')} /></Box>)
217 continue
218 }
219 const r = renderOf($, block.kind, block.source, theme)
220 if (!r) {
221 parts.push(
222 <Box flexDirection="column">
223 <Code language={codeLanguage(block.kind)} source={block.source} />
224 <Text dimColor>rendering {block.kind} diagram…</Text>
225 </Box>,
226 )
227 continue
228 }
229 if (!r.ok) {
230 parts.push(
231 <Box flexDirection="column">
232 <Code language={codeLanguage(block.kind)} source={block.source} />
233 <Text color="red">{block.kind} diagram did not render: {r.error}</Text>
234 </Box>,
235 )
236 continue
237 }
238 if (mode === 'text' || !r.png || !r.width || !r.height) {
239 parts.push(
240 r.text
241 ? <Box paddingY={1}><Text>{r.text}</Text></Box>
242 : <Box flexDirection="column"><Code language={codeLanguage(block.kind)} source={block.source} /><Text dimColor>({block.kind}: no text rendering for this diagram type; /diagram image draws it)</Text></Box>,
243 )
244 continue
245 }
246 const box = cellBox(r.width, r.height, maxColumns)
247 const alt = r.text ?? `[${block.kind} diagram: this terminal does not draw images; /diagram text shows it as box art]`
248 parts.push(
249 <Box paddingY={1}>
250 <Image source={{ png: r.png }} columns={box.columns} rows={box.rows} alt={alt} />
251 </Box>,
252 )
253 }
254
255 return (
256 <Box flexDirection="row">
257 <Box width={gutter}><Text>{e.props.isFirstOfReply ? '●' : ''}</Text></Box>
258 <Box flexDirection="column" flexGrow={1}>{parts}</Box>
259 </Box>
260 )
261 })
262
263 // `/diagram test`: a pane with a plain PNG and a rendered Mermaid sample, so
264 // "does this terminal draw pictures" is answered without a reply involved.
265 on('ui.render', { component: 'Pane', requestId: TEST_PANE }, async ($, e) => {
266 const facts = `viewport ${e.viewport?.columns ?? '?'}x${e.viewport?.rows ?? '?'} fullscreen=${String(e.viewport?.isFullscreen)} body=${e.props.bodyColumns}`
267 if (e.surface !== 'terminal') {
268 const { Box, Text } = $.ui.resolve(e)
269 return <Box flexDirection="column"><Text>{facts}</Text><Text dimColor>Image is a terminal-only element.</Text></Box>
270 }
271 const { Box, Text, Image } = $.ui.resolve(e)
272 await read($, generation)
273 const { theme } = await read($, settings)
274 const sample = 'flowchart LR\n A[Mermaid source] --> B[render.mjs]\n B --> C{Terminal draws images?}\n C -->|yes| D[PNG inline]\n C -->|no| E[Unicode box art]'
275 const r = renderOf($, 'mermaid', sample, theme)
276 const { base64 } = await $.fs.read(`${$.plugin.root}/assets/test.png`, { as: 'bytes' })
277 const width = Math.max(20, e.props.bodyColumns - 2)
278 let sampleRow: ReturnType<typeof Box> | ReturnType<typeof Text> | ReturnType<typeof Image>
279 if (!r) sampleRow = <Text dimColor>rendering the sample…</Text>
280 else if (!r.ok) sampleRow = <Text color="red">render failed: {r.error}</Text>
281 else if (!r.png || !r.width || !r.height) sampleRow = <Text color="red">render failed: no picture</Text>
282 else {
283 const box = cellBox(r.width, r.height, width)
284 sampleRow = <Image source={{ png: r.png }} columns={box.columns} rows={box.rows} alt={r.text ?? '[mermaid diagram]'} />
285 }
286 return (
287 <Box flexDirection="column">
288 <Text dimColor>{facts}</Text>
289 <Text>1. A plain PNG (gradient, green border, white diagonal):</Text>
290 <Image source={{ png: base64 }} columns={Math.min(40, width)} rows={10} alt="[no picture: this terminal did not draw the PNG]" />
291 <Text>2. A rendered Mermaid flowchart; where pictures are not drawn, its box art shows instead:</Text>
292 {sampleRow}
293 <Text dimColor>Esc closes this pane.</Text>
294 </Box>
295 )
296 })
297}
298types/index.d.ts 10 lines1export type DiagramMode = 'image' | 'text'
2export type DiagramTheme = 'dark' | 'light'
3export type DiagramSettings = { mode: DiagramMode; theme: DiagramTheme }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'tui-diagrams': { settings: DiagramSettings; generation: number }
8 }
9}
10