SLOPSHOPPER

mermaid-pane

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…

newrowstoastprocesstimer
★ 1v0.2.3MITupdated 2026-10-22youngOman/pill-mods/mods/mermaid-pane
A shopper browsing a rack in a slop shop
README

mermaid-pane

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.

  • Drawn in the direction you write (LR stays horizontal), scaled to fit the whole diagram in your terminal window so you never scroll. Rounded navy nodes with a soft glow, dashed group frames, transparent background. Chinese labels render correctly.
  • Each picture has two icon buttons at its top-right: download (to ~/Downloads) and copy image (to the clipboard).
  • Rendered once per diagram (cached by content) in about 2 s; shows "Drawing diagram…" while it works.
  • Pictures use the kitty graphics protocol, so you need a terminal that has it (Ghostty, kitty, cmux).

Setup

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/.

Source 2 files
hooks/register.tsx 261 lines
1import 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}
261
types/index.d.ts 7 lines
1export 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