Draws Mermaid diagrams in assistant replies, and a /mermaid-view pane for Markdown files

Claude Code mods by Siraj Samsudeen, as a plugin marketplace.
Draws Mermaid diagrams in Claude Code Desktop, where `mermaid blocks otherwise show as code:
`mermaid fence in an assistant reply is redrawn as the diagram, about a second after the reply arrives. The stored message keeps the Mermaid text./mermaid-view <path to .md> opens a pane beside the chat with the file's Markdown and its diagrams drawn, re-rendering whenever the file is saved..md file that holds a Mermaid block, the pane opens on it.Diagrams are rendered with Mermaid's CLI, @mermaid-js/mermaid-cli@11, through npx. It needs Node.js; the first diagram downloads the CLI and its headless Chrome (about 170 MB, cached afterwards). Labels are drawn as plain SVG text (htmlLabels: false).
The built-in file viewer is part of the app and no plugin can change it; /mermaid-view is the stand-in.
At the prompt of a terminal session:
/plugin install mermaid-pane --marketplace siraj-samsudeen/claude-mods
Answer y to add the marketplace, then pick a scope (user installs it for every session).
From a shell:
claude plugin install mermaid-pane --marketplace siraj-samsudeen/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with siraj-samsudeen/claude-mods, then Add plugin.
Sessions pick up a newly installed or updated plugin when they start.
./release.sh <plugin> [dev-folder]
Copies a session's dev folder into this catalogue when one is given, bumps the patch version, validates, refreshes the marketplace and updates the installed plugin.
hooks/register.tsx 270 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Segment, View } from '../types'
5
6const PANE = 'mermaid-view'
7const COMMAND = 'mermaid-view'
8const MMDC = ['npx', '-y', '-p', '@mermaid-js/mermaid-cli@11', 'mmdc']
9const SVG_LIMIT = 131072
10// Indented fences too: a reply often nests a diagram inside a list item.
11const FENCE = /^[ \t]*```mermaid[^\n]*\n([\s\S]*?)^[ \t]*```[ \t]*$/gm
12// Plain SVG text labels: the pane draws an Svg as an image, where HTML
13// labels inside <foreignObject> may not survive.
14const MERMAID_CONFIG = JSON.stringify({ htmlLabels: false, flowchart: { htmlLabels: false } })
15
16const view = atom({ plugin: 'mermaid-pane', key: 'view' } as const, {
17 path: null,
18 segments: [],
19 status: '',
20} as View)
21
22type Rendered = { svg: string } | { error: string }
23
24// Rendered SVG (or error) by diagram source, shared by the pane and the chat;
25// lives until the module reloads.
26const rendered = new Map<string, Rendered>()
27const inFlight = new Map<string, Promise<Rendered>>()
28// One mmdc at a time: each launches a headless Chrome.
29let queue: Promise<unknown> = Promise.resolve()
30let workDir: string | undefined
31let lastMtime = -1
32let isBusy = false
33let isStale = false
34
35const hash = (text: string) => {
36 let h = 5381
37 for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
38 return (h >>> 0).toString(36)
39}
40
41const diagrams = (text: string) => [...text.matchAll(FENCE)].map(m => m[1] ?? '')
42
43const split = (text: string): Segment[] => {
44 const segments: Segment[] = []
45 let at = 0
46 for (const match of text.matchAll(FENCE)) {
47 const prose = text.slice(at, match.index)
48 if (prose.trim()) segments.push({ kind: 'markdown', text: prose })
49 const source = match[1] ?? ''
50 const done = rendered.get(source)
51 if (!done) segments.push({ kind: 'pending', source })
52 else if ('svg' in done) segments.push({ kind: 'diagram', svg: done.svg, source })
53 else segments.push({ kind: 'failed', source, error: done.error })
54 at = match.index + match[0].length
55 }
56 const rest = text.slice(at)
57 if (rest.trim()) segments.push({ kind: 'markdown', text: rest })
58 return segments
59}
60
61const ensureWorkDir = async ($: EngineInterface) => {
62 if (workDir) return workDir
63 const made = await $.process.run(['mktemp', '-d', '-t', 'mermaid-pane'])
64 workDir = made.stdout.trim()
65 await $.fs.write(`${workDir}/config.json`, MERMAID_CONFIG)
66 return workDir
67}
68
69// Mermaid's own message (e.g. "Parse error on line 7" and its pointer),
70// without the stack trace or the parser's list of tokens it expected.
71const shortError = (output: string) => {
72 const lines = output.trim().split('\n')
73 const stack = lines.findIndex(line => /^\s*at |\((https?|file):\/\//.test(line))
74 const message = (stack < 0 ? lines : lines.slice(0, stack)).filter(
75 line => line.trim() && !line.startsWith('Expecting '),
76 )
77 return message.map(line => (line.length > 200 ? `${line.slice(0, 200)}…` : line)).join('\n').slice(0, 2000)
78}
79
80const renderDiagram = async ($: EngineInterface, source: string): Promise<Rendered> => {
81 const dir = await ensureWorkDir($)
82 const name = hash(source)
83 const input = `${dir}/${name}.mmd`
84 const output = `${dir}/${name}.svg`
85 await $.fs.write(input, source)
86 // The first run downloads the CLI and its headless Chrome, hence the long wait.
87 const ran = await $.process
88 .run([...MMDC, '-q', '-i', input, '-o', output, '-c', `${dir}/config.json`, '-b', 'white'], {
89 timeoutMs: 600_000,
90 })
91 .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
92 if (ran.exitCode !== 0) return { error: shortError(ran.stderr || ran.stdout) }
93 const svg = await $.fs.read(output)
94 if (svg.length > SVG_LIMIT) return { error: `SVG is ${svg.length} characters; the pane draws at most ${SVG_LIMIT}.` }
95 return { svg }
96}
97
98const ensureRendered = ($: EngineInterface, source: string): Promise<Rendered> => {
99 const done = rendered.get(source)
100 if (done) return Promise.resolve(done)
101 const running = inFlight.get(source)
102 if (running) return running
103 const job = queue
104 .then(() => renderDiagram($, source))
105 .catch((error: unknown) => ({ error: String(error) }))
106 .then(result => {
107 rendered.set(source, result)
108 inFlight.delete(source)
109 return result
110 })
111 queue = job
112 inFlight.set(source, job)
113 return job
114}
115
116const refresh = async ($: EngineInterface) => {
117 if (isBusy) {
118 isStale = true
119 return
120 }
121 isBusy = true
122 try {
123 do {
124 isStale = false
125 const { path } = await read($, view)
126 if (!path) return
127 const text = await $.fs.read(path)
128 await update($, view, v => ({ ...v, segments: split(text), status: '' }))
129 const todo = diagrams(text).filter(s => !rendered.has(s))
130 for (const [i, source] of todo.entries()) {
131 await update($, view, v => ({ ...v, status: `Rendering diagram ${i + 1} of ${todo.length}…` }))
132 await ensureRendered($, source)
133 await update($, view, v => ({ ...v, segments: split(text) }))
134 }
135 await update($, view, v => ({ ...v, status: '' }))
136 } while (isStale)
137 } catch (error) {
138 await update($, view, v => ({ ...v, status: `Could not read the file: ${String(error)}` }))
139 } finally {
140 isBusy = false
141 }
142}
143
144const poll = async ($: EngineInterface) => {
145 const { path } = await read($, view)
146 if (!path) return
147 const stat = await $.fs.stat(path).catch(() => undefined)
148 if (!stat || stat.mtimeMs === lastMtime) return
149 lastMtime = stat.mtimeMs
150 await refresh($)
151}
152
153const title = (path: string | null) => (path ? path.split('/').pop() ?? path : 'Mermaid view')
154
155// Points the pane at a file and opens it; the next poll renders it.
156const show = async ($: EngineInterface, path: string) => {
157 await update($, view, v => (v.path === path ? v : { path, segments: [], status: 'Loading…' }))
158 lastMtime = -1
159 const opened = await $.ui.open({ id: PANE, title: title(path) })
160 void poll($)
161 return opened
162}
163
164// After a successful write to a .md file that holds a mermaid fence, show it.
165const autoOpen = async ($: EngineInterface, path: string | undefined) => {
166 if (!path?.endsWith('.md')) return
167 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
168 if (!stat?.realPath) return
169 const text = await $.fs.read(stat.realPath).catch(() => '')
170 if (diagrams(text).length > 0) await show($, stat.realPath)
171}
172
173const afterWrite = ($: EngineInterface, path: string, result: ToolCallResult) => {
174 if (!('deny' in result && result.deny) && !result.isError) void autoOpen($, path).catch(() => undefined)
175 return result
176}
177
178export const register: Register = on => {
179 on('session.start', async ($, e, next) => {
180 await $.command.register({
181 name: COMMAND,
182 description: 'Show a Markdown file in a pane with its Mermaid diagrams drawn',
183 argumentHint: '<path to .md file>',
184 })
185 $.clock.every(1000, () => void poll($))
186 const { path } = await read($, view)
187 if (path) void $.ui.open({ id: PANE, title: title(path) })
188
189 return next(e)
190 })
191
192 on('command.run', { command: COMMAND }, async ($, e) => {
193 const path = e.args.trim()
194 if (!path) return { text: 'Usage: /mermaid-view <path to .md file>' }
195 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
196 if (!stat || stat.kind !== 'file' || !stat.realPath) return { text: `No file at ${path}.` }
197 try {
198 const opened = await show($, stat.realPath)
199
200 return { text: opened.isPlaced ? `Showing ${path}.` : `Pane is waiting for room: ${opened.reason}` }
201 } catch (error) {
202 return { text: `Could not open the pane: ${String(error)}` }
203 }
204 })
205
206 on('tool.call', { tool: 'Write' }, async ($, e, next) => afterWrite($, e.file_path, await next(e)))
207 on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterWrite($, e.file_path, await next(e)))
208
209 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
210 const { path, segments, status } = await read($, view)
211 const ui = $.ui.resolve(e)
212 const { Box, Text, Markdown, Code } = ui
213
214 return (
215 <Box flexDirection="column" gap={1}>
216 {!path && <Text dimColor>Run /mermaid-view <path> to show a file here.</Text>}
217 {status && <Text dimColor>{status}</Text>}
218 {segments.map((segment, i) => {
219 if (segment.kind === 'markdown') return <Markdown key={`m${i}`} text={segment.text} />
220 if (segment.kind === 'diagram' && 'Svg' in ui) {
221 const { Svg } = ui
222 return <Svg source={segment.svg} alt={`Mermaid diagram ${i + 1}`} />
223 }
224 return (
225 <Box flexDirection="column">
226 {segment.kind === 'pending' && <Text dimColor>Rendering diagram…</Text>}
227 {segment.kind === 'failed' && <Text color="red">Mermaid failed:</Text>}
228 {segment.kind === 'failed' && <Code source={segment.error} />}
229 <Code language="mermaid" source={segment.source} />
230 </Box>
231 )
232 })}
233 </Box>
234 )
235 })
236
237 // A reply with closed mermaid fences: drawn as the engine draws it until
238 // every diagram is rendered, then redrawn with the diagrams in place.
239 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
240 const ui = $.ui.resolve(e)
241 if (!('Svg' in ui)) return next(e)
242 const text = e.props.text
243 const sources = diagrams(text)
244 if (sources.length === 0) return next(e)
245 const missing = sources.filter(s => !rendered.has(s))
246 if (missing.length > 0) {
247 void Promise.all(missing.map(s => ensureRendered($, s))).then(() => $.ui.invalidate('ui.render'))
248 return next(e)
249 }
250 const { Box, Markdown, Svg } = ui
251
252 return (
253 <Box flexDirection="column" gap={1}>
254 {split(text).map((segment, i) =>
255 segment.kind === 'diagram' ? (
256 <Svg source={segment.svg} alt={`Mermaid diagram ${i + 1}`} />
257 ) : segment.kind === 'markdown' ? (
258 <Markdown text={segment.text} />
259 ) : (
260 // A diagram Mermaid rejected: the fence as written, then why.
261 <Markdown
262 text={`\`\`\`mermaid\n${segment.source}\`\`\`\n\n*Mermaid failed:*\n\n\`\`\`\n${segment.kind === 'failed' ? segment.error : 'not rendered'}\n\`\`\``}
263 />
264 ),
265 )}
266 </Box>
267 )
268 })
269}
270types/index.d.ts 20 lines1// One piece of the file as the pane draws it: prose, a rendered diagram,
2// a diagram still rendering, or one that failed (with the renderer's message).
3export type Segment =
4 | { kind: 'markdown'; text: string }
5 | { kind: 'diagram'; svg: string; source: string }
6 | { kind: 'pending'; source: string }
7 | { kind: 'failed'; source: string; error: string }
8
9export type View = {
10 path: string | null
11 segments: Segment[]
12 status: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'mermaid-pane': { view: View }
18 }
19}
20