Draws the mermaid diagrams of each answer as images in a pane (/show-me); needs mmdc and a kitty-graphics terminal such as Ghostty

A pane that draws the mermaid diagrams of each answer as images.
<img src="../docs/show-me.png" alt="The show-me pane drawing a flowchart from an answer, with keys to step through, open and close" width="780">
`mermaid fences, the pane opens and draws each one as a picture once the turn ends./show-me <question>: sends the question with a request to answer in mermaid diagrams.p and n step back into earlier turns. The header shows which turn a diagram came from. A diagram whose source is already in the history shows at once, without a new render./show-me: opens the pane again with the history.p and n step through the diagrams, o opens the PNG in the system viewer, and x or Esc closes the pane.If rendering fails, the pane shows the first error line and the diagram's source. If mmdc is not installed, a toast and the pane give the install command.
mmdc on PATH. On macOS with Google Chrome installed, skip puppeteer's browser download; the mod points mmdc at the installed Chrome: PUPPETEER_SKIP_DOWNLOAD=1 npm i -g @mermaid-js/mermaid-cli
Elsewhere, install it normally so puppeteer brings its own browser.
claude plugin marketplace add arasovic/claude-code-mods
claude plugin install show-me@claude-code-mods
Restart Claude Code and type /show-me how does this request flow through the app.
/show-me opens the pane.$TMPDIR/show-me/, one folder per turn. Each render deletes the turn folders older than a day, except the ones the history still shows.claude plugin validate .
claude plugin test .
../typecheck.sh show-mehooks/register.tsx 198 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Diagram } from '../types'
5
6const PANE = 'show-me'
7const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
8const ASK = 'Answer with one or more mermaid diagrams in ```mermaid fences (prefer flowchart LR), each followed by a short explanation.'
9
10const diagrams = atom({ plugin: 'show-me', key: 'diagrams' } as const, [] as Diagram[])
11const index = atom({ plugin: 'show-me', key: 'index' } as const, 0)
12
13export const extractMermaid = (text: string) => [...text.matchAll(/^```mermaid[^\n]*\n([\s\S]*?)^```/gm)].map(m => m[1]!.trim()).filter(Boolean)
14
15const titleOf = (source: string) => source.split('\n').find(l => l.trim() && !l.trim().startsWith('%%'))?.trim() ?? 'diagram'
16
17const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
18
19// Width and height from the PNG's IHDR chunk: bytes 16-23, big-endian. Anything that is not a PNG gives undefined.
20export const pngSize = (base64: string) => {
21 const bytes = Uint8Array.from(atob(base64.slice(0, 32)), c => c.charCodeAt(0))
22 if (bytes.length < 24 || PNG_SIGNATURE.some((b, i) => bytes[i] !== b)) return undefined
23 const v = new DataView(bytes.buffer)
24 return { width: v.getUint32(16), height: v.getUint32(20) }
25}
26
27// A terminal cell is about twice as tall as it is wide.
28export const fitImage = (width: number, height: number, maxCols: number, maxRows: number) => {
29 const clamp = (n: number, hi: number) => Math.max(1, Math.min(hi, 255, Math.round(n)))
30 let columns = maxCols
31 let rows = (columns * height) / width / 2
32 if (rows > maxRows) {
33 rows = maxRows
34 columns = (rows * 2 * width) / height
35 }
36 return { columns: clamp(columns, maxCols), rows: clamp(rows, maxRows) }
37}
38
39const DAY = 24 * 60 * 60 * 1000
40// ponytail: fixed history cap; make it a userConfig option if someone needs more.
41const MAX_DIAGRAMS = 30
42
43// A new turn's diagrams go after the history; the oldest drop past the cap.
44export const addTurn = (list: Diagram[], added: Diagram[], max = MAX_DIAGRAMS) => [...list, ...added].slice(-max)
45
46// A turn's rendered diagrams replace its placeholders by source, so one the cap dropped mid-render shifts nothing.
47export const replaceTurn = (list: Diagram[], turnId: string, drawn: Diagram[]) =>
48 list.map(d => (d.turnId === turnId ? (drawn.find(x => x.source === d.source) ?? d) : d))
49
50// A source the history already drew reuses that PNG; only new sources go to mmdc.
51export const cachedDraw = (list: Diagram[], source: string) => list.findLast(d => d.source === source && d.png && !d.error)
52
53// The turn folders the history still points at: its own turns and the folders its cached PNGs live in.
54export const keptFolders = (list: Diagram[]) => new Set(list.flatMap(d => [d.turnId, ...(d.png ? [d.png.split('/').slice(-2)[0]!] : [])]))
55
56// Other sessions share this folder, so only turns older than a day go; a newer one may still be on screen.
57// The history's own folders stay whatever their age.
58const sweep = async ($: EngineInterface, root: string, keep: Set<string>) => {
59 const entries = await $.fs.list(root).catch(() => [])
60 const old = []
61 for (const entry of entries) {
62 if (entry.kind !== 'dir' || keep.has(entry.name)) continue
63 const stat = await $.fs.stat(`${root}/${entry.name}`).catch(() => null)
64 if (stat && Date.now() - stat.mtimeMs > DAY) old.push(`${root}/${entry.name}`)
65 }
66 if (old.length) await $.process.run(['rm', '-rf', ...old]).catch(() => {})
67}
68
69const render = async ($: EngineInterface, sources: string[], turnId: string) => {
70 const root = `${((await $.env.get('TMPDIR')) ?? '/tmp/').replace(/\/?$/, '/')}show-me`
71 await sweep($, root, keptFolders(await read($, diagrams)))
72 const dir = `${root}/${turnId}`
73 // mmdc renders every fence of a markdown file in one browser launch, as out-1.png, out-2.png, ...
74 await $.fs.write(`${dir}/in.md`, sources.map(s => '```mermaid\n' + s + '\n```').join('\n\n'))
75 // Use the installed Chrome when there is one, so mmdc needs no browser download of its own.
76 const hasChrome = await $.fs.exists(CHROME)
77 if (hasChrome) await $.fs.write(`${dir}/puppeteer.json`, JSON.stringify({ executablePath: CHROME, headless: 'shell' }))
78 const run = await $.process
79 .run(['mmdc', ...(hasChrome ? ['-p', `${dir}/puppeteer.json`] : []), '-i', `${dir}/in.md`, '-o', `${dir}/out.md`, '-e', 'png', '-t', 'dark', '-b', 'transparent', '-s', '2'], { timeoutMs: 120_000 })
80 .catch(async (err: unknown) => {
81 // A rejection means mmdc could not start or ran past the timeout; only a missing mmdc gets the install hint.
82 const found = await $.process.run(['sh', '-c', 'command -v mmdc']).then(r => r.exitCode === 0, () => true)
83 if (found) return { exitCode: 1, stderr: String(err) }
84 const hint = `mmdc is not installed: ${hasChrome ? 'PUPPETEER_SKIP_DOWNLOAD=1 ' : ''}npm i -g @mermaid-js/mermaid-cli`
85 $.ui.toast(`show-me: ${hint}`)
86 return { exitCode: 127, stderr: hint }
87 })
88 const error = run.exitCode === 0 ? undefined : run.stderr.trim().split('\n')[0] || `mmdc exited ${run.exitCode}`
89 return Promise.all(
90 sources.map(async (source, i): Promise<Diagram> => {
91 const title = titleOf(source)
92 if (error) return { turnId, title, source, error }
93 const png = `${dir}/out-${i + 1}.png`
94 const bytes = await $.fs.read(png, { as: 'bytes' }).catch(() => null)
95 const size = bytes && pngSize(bytes.base64)
96 return size ? { turnId, title, source, png, ...size } : { turnId, title, source, error: `mmdc wrote no valid PNG at ${png}` }
97 }),
98 )
99}
100
101// Other surfaces have no Image, so the PNG goes inside an Svg, at half its pixels since mmdc draws at scale 2.
102// Undefined while there is no PNG, or past the Svg's 131072-character cap.
103export const svgOf = (d: Diagram, base64: string) => {
104 if (!d.width || !d.height) return undefined
105 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${d.width / 2}" height="${d.height / 2}" viewBox="0 0 ${d.width} ${d.height}"><image href="data:image/png;base64,${base64}" width="${d.width}" height="${d.height}"/></svg>`
106 return svg.length <= 131072 ? svg : undefined
107}
108
109// No focus: a focused pane takes the arrows and the hotkey letters away from the prompt.
110// The person clicks the pane (or ctrl+x tab) to use its keys.
111const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Show me', closeOnEscape: true })
112
113export const register: Register = on => {
114 on('session.start', async ($, e, next) => {
115 await $.command.register({ name: 'show-me', description: 'Ask for an answer as mermaid diagrams, or open the diagram pane', argumentHint: '[question]' })
116 return next(e)
117 })
118
119 on('command.run', { command: 'show-me' }, async ($, e) => {
120 const question = e.args.trim()
121 if (question) {
122 // The engine refuses a submit while command.run holds the turn, so it goes out once the command is done.
123 $.clock.after(0, () =>
124 $.prompt.submit({ text: `${question}\n\n${ASK}`, asUser: true }).catch((err: unknown) => $.ui.toast(`show-me: ${String(err)}`)),
125 )
126 return {}
127 }
128 const opened = await open($)
129 return { text: opened.isPlaced ? 'Diagram pane opened.' : `Diagram pane is waiting: ${opened.reason}` }
130 })
131
132 on('turn.complete', async ($, e, next) => {
133 const result = await next(e)
134 const sources = e.agentId || e.reason !== 'answer' ? [] : extractMermaid(e.answer)
135 if (sources.length === 0) return result
136 // Rendering launches a browser; it runs after the turn so the turn ends on time.
137 $.clock.after(0, async () => {
138 const before = await read($, diagrams)
139 const added = sources.map(source => ({ ...cachedDraw(before, source), turnId: e.turnId, title: titleOf(source), source }))
140 const history = await update($, diagrams, list => addTurn(list, added))
141 // The pane jumps to the turn's first diagram.
142 await update($, index, () => Math.max(0, history.length - sources.length))
143 const opened = await open($)
144 if (!opened.isPlaced) $.ui.toast(`show-me: ${sources.length} diagram(s), /show-me to open`)
145 const fresh = [...new Set(added.filter(d => !d.png).map(d => d.source))]
146 if (fresh.length === 0) return
147 const drawn = await render($, fresh, e.turnId)
148 await update($, diagrams, list => replaceTurn(list, e.turnId, drawn))
149 })
150 return result
151 })
152
153 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
154 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
155 const list = await read($, diagrams)
156 const close = <Button key="close" plain hotkey="x" label="close" onPress={() => $.ui.close({ id: PANE })} />
157 const isTerminal = e.surface === 'terminal'
158 const hint = isTerminal && <Text dimColor>Click the pane or press ctrl+x tab to use its keys</Text>
159 if (list.length === 0) return <Box flexDirection="column" paddingTop={1}><Box gap={1}><Text dimColor>No diagrams yet. Ask with /show-me.</Text>{close}</Box>{hint}</Box>
160 const i = Math.min(await read($, index), list.length - 1)
161 const d = list[i]!
162 const turns = [...new Set(list.map(x => x.turnId))]
163 const step = (by: number) => () => update($, index, n => (n + by + list.length) % list.length)
164 const cols = Math.max(1, e.props.bodyColumns)
165 const room = Math.max(1, (e.viewport?.rows ?? 24) - 5)
166
167 let body
168 if (!isTerminal) {
169 const bytes = d.png && !d.error ? await $.fs.read(d.png, { as: 'bytes' }).catch(() => null) : null
170 const svg = bytes && svgOf(d, bytes.base64)
171 const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
172 // Without a picture the source goes out as a mermaid fence, which a surface may draw itself.
173 body = svg ? <Svg source={svg} alt={d.title} /> : <Markdown text={'```mermaid\n' + d.source + '\n```'} />
174 } else if (d.error) body = <Box flexDirection="column"><Text color="red">{d.error}</Text><Text dimColor>{d.source}</Text></Box>
175 else if (!d.png || !d.width || !d.height) body = <Text dimColor>rendering…</Text>
176 else {
177 const { Image } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
178 body = <Image source={{ file: d.png, format: 'png' }} {...fitImage(d.width, d.height, cols, room)} alt={d.title} />
179 }
180
181 return (
182 <Box flexDirection="column" paddingTop={1}>
183 <Box flexDirection="row" gap={1}>
184 <Text bold>{`${i + 1}/${list.length}`}</Text>
185 <Text dimColor>{`turn ${turns.indexOf(d.turnId) + 1}/${turns.length}`}</Text>
186 <Text>{d.title}</Text>
187 <Button key="prev" plain hotkey="p" label="‹" onPress={step(-1)} />
188 <Button key="next" plain hotkey="n" label="›" onPress={step(1)} />
189 {d.png && <Button key="open" plain hotkey="o" label="open" onPress={() => void $.process.run(['open', d.png!])} />}
190 {close}
191 </Box>
192 {hint}
193 {body}
194 </Box>
195 )
196 })
197}
198types/index.d.ts 11 lines1export type Diagram = { turnId: string; title: string; source: string; png?: string; width?: number; height?: number; error?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'show-me': {
6 diagrams: Diagram[]
7 index: number
8 }
9 }
10}
11