SLOPSHOPPER

mermaid-pane

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

newpanerowsguardcommandprocess
v0.2.3no licenseupdated 2026-10-23siraj-samsudeen/claude-mods/mermaid-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mermaid-pane
│ ┃ mermaid-view ✕ › fix the failing auth test and add an audit log call │ ┃ Run /mermaid-view <path> to show a file │ ┃ here. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mermaid-view │ ⎿ mermaid-pane: Usage: /mermaid-view <path to .md file> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mermaid-view
Run /mermaid-view <path> to show a file here.
README

claude-mods

Claude Code mods by Siraj Samsudeen, as a plugin marketplace.

mermaid-pane

Draws Mermaid diagrams in Claude Code Desktop, where `mermaid blocks otherwise show as code:

  • In chat: a closed `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.
  • In a pane: /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.
  • Automatically: when Claude writes or edits a .md file that holds a Mermaid block, the pane opens on it.
  • Errors: a diagram Mermaid rejects shows Mermaid's own parse error, with its pointer, under the source.

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.

Install

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.

Releasing a change

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

Source 2 files
hooks/register.tsx 270 lines
1import { 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 &lt;path&gt; 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}
270
types/index.d.ts 20 lines
1// 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