Draws mermaid blocks in Claude's replies as pictures right in the conversation: navy nodes with light-blue borders on a transparent background, download and…

A Claude Code mod that draws mermaid diagrams right in the conversation: any `mermaid block in Claude's reply is replaced by a picture, so you see the architecture instead of raw flowchart text.
Needs Node and Google Chrome (macOS path /Applications/Google Chrome.app). Install the renderer once for fast draws: PUPPETEER_SKIP_DOWNLOAD=1 npm i -g @mermaid-js/mermaid-cli. Without it the first render goes through npx and takes about 30 s.
/plugin marketplace add youngOman/pill-mods
/plugin install mermaid-pane@pill-mods
/reload-plugins
Pictures and their sources are kept in ~/.claude/mermaid-pane/.
hooks/register.tsx 261 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Rendered } from '../types'
4
5const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
6// 配色對齊深色終端:深藍底、cyan 邊框、淺灰線。
7const THEME = {
8 theme: 'base',
9 themeVariables: {
10 background: 'transparent',
11 primaryColor: '#16243d',
12 primaryBorderColor: '#7DD3FC',
13 primaryTextColor: '#FFFFFF',
14 secondaryColor: '#16243d',
15 tertiaryColor: 'transparent',
16 lineColor: '#94A3B8',
17 textColor: '#F1F5F9',
18 edgeLabelBackground: '#0a0c10',
19 clusterBkg: 'rgba(125,211,252,0.04)',
20 clusterBorder: '#475569',
21 titleColor: '#CBD5E1',
22 fontSize: '20px',
23 fontFamily: 'PingFang TC, Noto Sans TC, sans-serif',
24 },
25 flowchart: { nodeSpacing: 40, rankSpacing: 55, padding: 16, curve: 'basis', htmlLabels: true },
26 themeCSS: `
27 .node rect, .node polygon { rx: 12px; ry: 12px; stroke-width: 1.6px; filter: drop-shadow(0 0 8px rgba(125,211,252,0.28)); }
28 .node .label, .nodeLabel { font-weight: 600; }
29 .cluster rect { rx: 16px; ry: 16px; stroke-width: 1.3px; stroke-dasharray: 7 5; }
30 .cluster-label .nodeLabel, .cluster text { fill: #CBD5E1; font-weight: 500; }
31 .edgePath path, .flowchart-link { stroke-width: 1.6px; }
32 .edgeLabel { background-color: #0a0c10; color: #E2E8F0; border-radius: 8px; padding: 2px 8px; }
33 .edgeLabel rect { rx: 8px; ry: 8px; opacity: 1; }
34 marker path { fill: #94A3B8; stroke: #94A3B8; }
35 `,
36}
37// 改樣式時遞增,舊快取的圖才會重畫。
38const STYLE = 'v6'
39
40// 以 mermaid 原始碼的雜湊為鍵:同一張圖只畫一次。繪圖階段不准寫 $.state,所以只用模組變數。
41const cache = new Map<string, Rendered>()
42const inflight = new Map<string, Promise<Rendered>>()
43let dir: string | undefined
44
45/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
46function pngSize(base64: string): { width: number; height: number } | null {
47 const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
48 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
49 if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
50 const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
51 const width = view.getUint32(16)
52 const height = view.getUint32(20)
53 return width > 0 && height > 0 ? { width, height } : null
54}
55
56function hash(text: string): string {
57 let h = 5381
58 for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
59 return h.toString(36)
60}
61
62type Part = { kind: 'text'; text: string } | { kind: 'mermaid'; code: string }
63
64/** Splits a reply into prose and ```mermaid blocks, in order. */
65function split(text: string): Part[] {
66 const parts: Part[] = []
67 let last = 0
68 for (const m of text.matchAll(/^[ \t]*```mermaid[^\n]*\n([\s\S]*?)^[ \t]*```[ \t]*$/gm)) {
69 const at = m.index ?? 0
70 if (at > last) parts.push({ kind: 'text', text: text.slice(last, at) })
71 parts.push({ kind: 'mermaid', code: (m[1] ?? '').trim() })
72 last = at + m[0].length
73 }
74 if (last < text.length) parts.push({ kind: 'text', text: text.slice(last) })
75 return parts
76}
77
78async function outDir($: EngineInterface): Promise<string> {
79 if (dir) return dir
80 const { stdout } = await $.process.run([
81 'sh', '-c', 'mkdir -p "$HOME/.claude/mermaid-pane" && printf %s "$HOME/.claude/mermaid-pane"',
82 ])
83 dir = stdout.trim()
84 await $.fs.write(`${dir}/theme.json`, JSON.stringify(THEME))
85 await $.fs.write(`${dir}/chrome.json`, JSON.stringify({ executablePath: CHROME, args: ['--no-sandbox'] }))
86 return dir
87}
88
89/** Renders one mermaid source to a PNG; mmdc if installed, else npx (slower the first time). */
90async function render($: EngineInterface, key: string, code: string): Promise<Rendered> {
91 const base = await outDir($)
92 const input = `${base}/${key}.mmd`
93 const output = `${base}/${key}.png`
94 if (await $.fs.exists(output)) {
95 const size = await $.fs.read(output, { as: 'bytes' }).then(({ base64 }) => pngSize(base64), () => null)
96 return { file: output, size }
97 }
98 await $.fs.write(input, prepare(code))
99 const run = await $.process.run(
100 [
101 'sh', '-c',
102 'M=$(command -v mmdc || echo "npx -y @mermaid-js/mermaid-cli"); ' +
103 'PUPPETEER_SKIP_DOWNLOAD=1 $M -q -i "$1" -o "$2" -p "$3" -c "$4" -s 2 -b transparent',
104 'sh', input, output, `${base}/chrome.json`, `${base}/theme.json`,
105 ],
106 { timeoutMs: 180_000 },
107 ).catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
108 if (run.exitCode !== 0) {
109 await $.fs.write(`${base}/${key}.err`, `exit ${run.exitCode}\n${run.stderr}\n${run.stdout}`).catch(() => undefined)
110 }
111 if (run.exitCode !== 0) return { file: null, size: null, error: (run.stderr || run.stdout || 'render failed').trim().slice(-200) }
112 const size = await $.fs.read(output, { as: 'bytes' }).then(({ base64 }) => pngSize(base64), () => null)
113 return { file: output, size }
114}
115
116/** 存一份到 ~/Downloads(同名不覆蓋,自動加序號),回報最後檔名。 */
117async function download($: EngineInterface, file: string, key: string): Promise<void> {
118 const { stdout, exitCode } = await $.process.run([
119 'sh', '-c',
120 'mkdir -p "$HOME/Downloads"; n="$HOME/Downloads/mermaid-$2.png"; i=1; ' +
121 'while [ -e "$n" ]; do i=$((i+1)); n="$HOME/Downloads/mermaid-$2-$i.png"; done; cp "$1" "$n" && printf %s "$n"',
122 'sh', file, key,
123 ])
124 if (exitCode === 0) void $.ui.toast(`已存到 ${stdout.trim().replace(/^.*\/Downloads\//, '~/Downloads/')}`)
125 else void $.ui.toast('下載失敗')
126}
127
128/** 把圖片放進剪貼簿(macOS),可直接貼到簡報、聊天。 */
129async function copyImage($: EngineInterface, file: string): Promise<void> {
130 const { exitCode } = await $.process.run([
131 'osascript', '-e', `set the clipboard to (read (POSIX file "${file}") as «class PNGf»)`,
132 ])
133 void $.ui.toast(exitCode === 0 ? '已複製圖片' : '複製失敗')
134}
135
136/** 原樣交給 mermaid(方向由寫的人決定,預設橫式 LR)。 */
137function prepare(code: string): string {
138 return code
139}
140
141/** 渲染一張圖(同一張只跑一次);只能在不會被取消的 hook(turn.complete)裡呼叫。 */
142function ensure($: EngineInterface, code: string): Promise<Rendered> {
143 const key = hash(STYLE + code)
144 const hit = cache.get(key)
145 if (hit) return Promise.resolve(hit)
146 let job = inflight.get(key)
147 if (!job) {
148 job = render($, key, code).then(value => {
149 inflight.delete(key)
150 if (value.file) cache.set(key, value)
151 return value
152 })
153 inflight.set(key, job)
154 }
155 return job
156}
157
158const spawned = new Set<string>()
159const polls = new Map<string, number>()
160
161/**
162 * 背景啟動渲染(shell 立刻返回,所以不會被引擎取消),再用計時器輪詢重繪。
163 * 這是 turn.complete 之外的保險:舊訊息、重載後、別的 hook 沒畫到的圖都靠它。
164 */
165async function kick($: EngineInterface, code: string): Promise<void> {
166 const key = hash(STYLE + code)
167 const tries = polls.get(key) ?? 0
168 if (tries >= 15) return
169 polls.set(key, tries + 1)
170 if (!spawned.has(key)) {
171 spawned.add(key)
172 const base = await outDir($)
173 await $.fs.write(`${base}/${key}.mmd`, prepare(code))
174 await $.process.run([
175 'sh', '-c',
176 'M=$(command -v mmdc || echo "npx -y @mermaid-js/mermaid-cli"); ' +
177 '( PUPPETEER_SKIP_DOWNLOAD=1 nohup $M -q -i "$1" -o "$2" -p "$3" -c "$4" -s 2 -b transparent >"$5" 2>&1 ) >/dev/null 2>&1 &',
178 'sh', `${base}/${key}.mmd`, `${base}/${key}.png`, `${base}/chrome.json`, `${base}/theme.json`, `${base}/${key}.log`,
179 ])
180 }
181 $.clock.after(2000, () => $.ui.invalidate('ui.render'))
182}
183
184/** 繪圖階段只讀:快取沒有就看磁碟上有沒有已畫好的 PNG。 */
185async function lookup($: EngineInterface, code: string): Promise<Rendered | undefined> {
186 const key = hash(STYLE + code)
187 const hit = cache.get(key)
188 if (hit) return hit
189 const base = dir ?? (await $.process.run(['sh', '-c', 'printf %s "$HOME/.claude/mermaid-pane"'])).stdout.trim()
190 const file = `${base}/${key}.png`
191 if (!(await $.fs.exists(file))) return undefined
192 const size = await $.fs.read(file, { as: 'bytes' }).then(({ base64 }) => pngSize(base64), () => null)
193 const value: Rendered = { file, size }
194 cache.set(key, value)
195 return value
196}
197
198export const register: Register = on => {
199 // 回合結束時把這次回覆裡的 mermaid 區塊畫好(這個 hook 不會被重畫取消),再叫畫面重繪。
200 on('turn.complete', async ($, e, next) => {
201 const done = await next(e)
202 const codes = split(e.answer).flatMap(p => (p.kind === 'mermaid' ? [p.code] : []))
203 if (codes.length > 0) {
204 await Promise.all(codes.map(code => ensure($, code)))
205 $.ui.invalidate('ui.render')
206 }
207 return done
208 })
209
210 // 助理訊息裡的 ```mermaid 區塊,在對話中直接換成圖片(原文字不顯示)。
211 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
212 if (e.surface !== 'terminal' || !/^[ \t]*```mermaid/m.test(e.props.text)) return next(e)
213 const parts = split(e.props.text)
214 if (!parts.some(p => p.kind === 'mermaid')) return next(e)
215
216 const results: Record<string, Rendered> = {}
217 for (const part of parts) {
218 if (part.kind !== 'mermaid') continue
219 const value = await lookup($, part.code)
220 if (value) results[hash(STYLE + part.code)] = value
221 else await kick($, part.code)
222 }
223
224 const { Box, Text, Markdown, Image, Button } = $.ui.resolve(e)
225 const maxCols = Math.max(20, (e.viewport?.columns ?? 100) - 6)
226 const maxRows = Math.max(8, (e.viewport?.rows ?? 40) - 8) // 留給按鈕列、文字與輸入框
227
228 return (
229 <Box flexDirection="column">
230 {parts.map(part => {
231 if (part.kind === 'text') return part.text.trim() ? <Markdown text={part.text} /> : null
232 const key = hash(STYLE + part.code)
233 const result = results[key]
234 if (result?.error) return <Text color="red">Mermaid render failed: {result.error}</Text>
235 if (!result || result.file === null) return <Text dimColor>⏳ Drawing diagram…</Text>
236 const size = result.size ?? { width: 16, height: 9 }
237 // 終端機一格約 1:2:整張圖等比縮到寬、高都放得進視窗,不用滑動
238 const columns = Math.floor(Math.min(maxCols, (maxRows * 2 * size.width) / size.height))
239 const rows = Math.round((columns * size.height) / (2 * size.width))
240 const file = result.file
241 return (
242 <Box flexDirection="column">
243 <Box key={`mermaid-actions-${key}`} flexDirection="row" justifyContent="flex-end" columnGap={1}>
244 <Button key={`mermaid-dl-${key}`} variant="primary" label={'\u{F01DA}'} hover={{ bold: true }} onPress={() => void download($, file, key)} />
245 <Button key={`mermaid-cp-${key}`} variant="primary" label={'\u{F018F}'} hover={{ bold: true }} onPress={() => void copyImage($, file)} />
246 </Box>
247 <Image
248 key={`mermaid-${key}`}
249 source={{ file, format: 'png' }}
250 columns={Math.max(4, Math.min(255, columns))}
251 rows={Math.max(1, Math.min(255, rows))}
252 alt="mermaid diagram"
253 />
254 </Box>
255 )
256 })}
257 </Box>
258 )
259 })
260}
261types/index.d.ts 7 lines1export type Rendered = {
2 /** Absolute path of the PNG; null when rendering failed. */
3 file: string | null
4 size: { width: number; height: number } | null
5 error?: string
6}
7