A Claude Code mod: a browser editor for Mermaid diagrams, those of Claude's answers and those in your project's Markdown documents and .mmd files. Zoom…

Status: work in progress. mermaid-view is unfinished and still changing: features may be incomplete or rough, and how it works, what it stores and how it is configured may change without notice. Try it, but do not rely on it yet.
A Claude Code mod with a browser editor for Mermaid diagrams: the diagrams of Claude's answers, and those in your project's Markdown documents and .mmd files. View them zoomed and themed, edit their source with a real code editor linked to the drawing, keep their history, and hand your edits back to Claude.
It comes in two parts:
editor/): a small local server (127.0.0.1:8771, Python standard library) and its page. It runs on its own, without Claude Code..claude-plugin/, hooks/): starts the editor for a session (it stops when that Claude Code ends), turns the `mermaid blocks of Claude's answers into pages, opens the project documents Claude writes, and hands your edits back to Claude..mmd, SVG, PNG, Markdown, clipboard, Mermaid Live Editor, a zip of every page.diagram_edits./mcp, and WebMCP tools in the page.claude plugin marketplace add parkghost/mermaid-view
claude plugin install mermaid-view@parkghost-mermaid-view
The first command adds this GitHub repository as a plugin marketplace; the second installs the plugin from it. claude plugin marketplace update parkghost-mermaid-view picks up new releases.
Runs on Linux, WSL and macOS. Needs python3 3.9 or newer (standard library only; the one macOS ships will do), git for history (optional), and a way to open the browser: wslview (WSL), xdg-open (Linux) or open (macOS). Monaco, Mermaid and the icon sets come from cdn.jsdelivr.net the first time the page uses each file, and are kept in the data folder's vendor/: after that the editor works offline. When the editor cannot start, a toast says why and diagrams open as a static page instead of the editor, which needs the CDN.
Once installed, a Claude answer with a `mermaid block opens in the editor (with the option openAnswers off, it is kept as a page without opening); /mermaid shows the last page again, or before any diagram has been shown, opens the editor on the project's files.
python3 -I editor/mermaid_view.py --project ~/code/my-project --open
| Flag | |
|---|---|
--project DIR | Registers a folder whose documents and .mmd files hold diagrams (repeatable; registered projects are remembered) |
--open | Opens the browser on it |
--root DATA | The data folder |
--port N | The port (default 8771) |
--exit-with-parent | Stops once the process that started it ends (what the mod passes); otherwise it runs until stopped |
The editor's page is in English or Traditional Chinese: the browser's language by default, or the choice in the header. A note sent to Claude asks in the page's language.
Set in /config (or pluginConfigs["mermaid-view"].options in ~/.claude/settings.json):
| Option | Default | |
|---|---|---|
dataDir | ~/.local/share/mermaid-view | Pages, edits and their git history, and the page's libraries (vendor/, not in the history). Pages in the former ~/.cache/mermaid-view move here once. |
keepDays | 90 | Pages neither edited nor pinned are removed after this many days; 0 keeps them all. |
openAnswers | true | Open the diagrams of each answer in the editor; false keeps them as a page all the same, and /mermaid opens it. |
openFiles | true | Open a project document or .mmd file Claude writes that holds diagrams (once per file per session). |
reuseTab | true | Show a new answer's diagrams, or a project file Claude writes, in the editor tab already open instead of a new tab; false always opens a new tab. |
.mermaid-view.jsonclaude plugin test . # the mod's tests
python3 -I -m unittest discover -s editor # the editor's server and its MCP endpoint
node --test editor/static/*.test.mjs # the page's language tools, syntax reference, library and WebMCP tools
claude plugin validate .
uvx ruff@0.17.0 check . && uvx ruff@0.17.0 format --check . # Python lint and format
npx @biomejs/biome@2.5.15 ci . # JS, TS and JSON lint and format
Loaded with claude --plugin-dir ., saving a file reloads the mod. Contributors and coding agents: see AGENTS.md.
config-schema.json), the documentation examples the lint is tested on (docs-examples.json) and the syntax reference's facts.@mermaid-js/mermaid-zenuml, draws zenuml diagrams.hooks/register.ts 659 lines1// Mermaid diagrams in an answer, viewed and edited in the browser, and the edits back to Claude.
2//
3// The editor (editor/mermaid_view.py, a program of its own) runs for the session on 127.0.0.1;
4// this mod is Claude Code's side of it:
5// - when a main-loop turn ends with ```mermaid blocks, they become a page of the editor, shown
6// in a tab already open on it when there is one (option reuseTab; a tab with edits in
7// progress offers it instead of switching), else in a new tab;
8// - the session's project is registered with it, so its Markdown documents and .mmd files open
9// there too, and when Claude writes one that holds diagrams, it opens (once per file) and
10// follows the file from then on;
11// - the model reads the person's edits, with their diffs, through diagram_edits (a page's, or a
12// project file's), and finds earlier diagrams through diagram_search; a note sent from a
13// page puts a prompt asking for that into the input box, in the page's language.
14// Without python3 a page is a static file. /mermaid shows the last page again, the same way, or
15// with nothing shown yet, the editor itself: the session's project files, else its latest page.
16import type { EngineInterface, Register } from 'claude-code'
17
18export const MERMAID = 'https://cdn.jsdelivr.net/npm/mermaid@12.1.0/dist/mermaid.esm.min.mjs'
19// Tried in order: WSL (the Windows browser), Linux desktops, macOS.
20const OPENERS = [['wslview'], ['xdg-open'], ['open']]
21// The same port across reloads keeps open pages working; the server takes any free one if busy.
22const PORT = 8771
23const TOOL = 'diagram_edits'
24export const TOOL_NAME = 'mcp__mermaid-view__diagram_edits' // mcp__<plugin>__<TOOL>
25const SEARCH = 'diagram_search'
26export const SEARCH_NAME = 'mcp__mermaid-view__diagram_search'
27// The plugin's options (plugin.json userConfig), as register() receives them.
28type Options = { dataDir: string; keepDays: number; openAnswers: boolean; openFiles: boolean; reuseTab: boolean }
29let config: Options = {
30 dataDir: '~/.local/share/mermaid-view',
31 keepDays: 90,
32 openAnswers: true,
33 openFiles: true,
34 reuseTab: true,
35}
36// The project files the editor opens: Markdown documents and diagram files.
37const DIAGRAM_FILE = /\.(md|markdown|mdx|mmd|mermaid)$/i
38// Where pages were kept before the data folder became an option: moved once into it.
39const FORMER_ROOT = '~/.cache/mermaid-view'
40const POLL_MS = 1500
41const NO_SERVER = 'The mermaid-view page server is not running (python3 missing?)'
42
43// Module state: a reload starts without it (and the reload ended the server it started).
44let port: number | undefined
45let starting: Promise<number | undefined> | undefined
46let polling = false
47let warned = false // the editor failed to start, and the person was told why (once a module life)
48let delivering = false // a note on its way into the prompt box
49let lastPage: string | undefined // a URL or a file path
50let project: Promise<ProjectInfo | undefined> | undefined // the session's project, registered with the editor
51const openedFiles = new Set<string>() // project files opened this session (each opens once)
52
53type ProjectInfo = { id: string; name: string; root: string }
54
55// A code fence's opening line as CommonMark reads it: up to three spaces, a run of three or more
56// ` or ~, then the info string (a ` fence's holds no `). Its closing line: up to three spaces, a run
57// of the same character at least as long, then only spaces or tabs (editor/fences.py).
58const OPENING = /^( {0,3})(`{3,}|~{3,})(.*)$/
59const CLOSING = /^ {0,3}(`+|~+)[ \t]*$/
60
61/**
62 * The ```mermaid (or ~~~mermaid) blocks of Markdown text as CommonMark reads them, in order: the
63 * index of each one's opening line and its source, the fence's indentation taken off its lines. A
64 * block of another language hides what it holds; a block left open runs to the end of the text.
65 */
66function mermaidFences(text: string): { line: number; source: string }[] {
67 const lines = text.split(/\r?\n/)
68 const found: { line: number; source: string }[] = []
69 for (let i = 0; i < lines.length; i++) {
70 const [, indent = '', fence = '', info = ''] = OPENING.exec(lines[i] ?? '') ?? []
71 if (!fence || (fence[0] === '`' && info.includes('`'))) continue
72 let end = i + 1
73 while (end < lines.length && !closes(lines[end] ?? '', fence)) end++
74 if (/^[ \t]*mermaid\b/.test(info)) {
75 const body = lines.slice(i + 1, end).map(l => l.slice(Math.min(indent.length, /^ */.exec(l)?.[0].length ?? 0)))
76 found.push({ line: i, source: body.join('\n') })
77 }
78 i = end
79 }
80 return found
81}
82
83function closes(line: string, fence: string): boolean {
84 const run = CLOSING.exec(line)?.[1] ?? ''
85 return run[0] === fence[0] && run.length >= fence.length
86}
87
88/** The source of each ```mermaid block in text, in order. */
89export function mermaidBlocks(text: string): string[] {
90 return mermaidFences(text)
91 .map(f => f.source.trim())
92 .filter(Boolean)
93}
94
95const escapeHtml = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
96
97/** A static page of the diagrams, for when the page server cannot run. */
98export function page(diagrams: readonly string[], title: string): string {
99 const figures = diagrams
100 .map((d, i) => `<figure><figcaption>${i + 1}</figcaption><pre class="mermaid">${escapeHtml(d)}</pre></figure>`)
101 .join('\n')
102 return `<!doctype html>
103<html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
104<style>
105 :root { color-scheme: light dark; }
106 body { font-family: system-ui, sans-serif; margin: 24px; background: Canvas; color: CanvasText; }
107 figure { margin: 0 0 32px; overflow-x: auto; }
108 figcaption { opacity: .6; font-size: 12px; margin-bottom: 8px; }
109</style></head>
110<body>
111${figures}
112<script type="module">
113 import mermaid from '${MERMAID}'
114 const dark = matchMedia('(prefers-color-scheme: dark)').matches
115 mermaid.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'default', securityLevel: 'strict' })
116 await mermaid.run({ querySelector: 'pre.mermaid', suppressErrors: true })
117</script>
118</body></html>
119`
120}
121
122/** A diagram's kind: the first word of its first line that is not a comment ("flowchart"). */
123export function kindOf(diagram: string): string {
124 const first = diagram.split('\n').find(l => l.trim() && !l.trim().startsWith('%%')) ?? ''
125 return first.trim().split(/\s+/)[0] ?? ''
126}
127
128/**
129 * A page's title: the answer's last line of prose before its first diagram (a heading or the
130 * sentence that introduces it), without markdown; else the kind and the first node labels.
131 */
132export function titleFor(answer: string, diagrams: readonly string[]): string {
133 const line = answer
134 .split(/\r?\n/)
135 .slice(0, mermaidFences(answer)[0]?.line ?? 0)
136 .map(l =>
137 l
138 .replace(/^\s*(#+|>|[-*]|\d+\.)\s*/, '')
139 .replace(/[*`_]/g, '')
140 .trim(),
141 )
142 .filter(Boolean)
143 .pop()
144 if (line) return line.replace(/[::]\s*$/, '').slice(0, 80)
145 const first = diagrams[0] ?? ''
146 const labels = [...first.matchAll(/\[\s*"([^"]+)"|\{\s*"([^"]+)"|\(\s*"([^"]+)"/g)]
147 .map(m => m[1] ?? m[2] ?? m[3])
148 .slice(0, 3)
149 return [kindOf(first), labels.join('、')].filter(Boolean).join(' · ').slice(0, 80)
150}
151
152async function expandHome($: EngineInterface, path: string): Promise<string> {
153 return path.startsWith('~') ? `${(await $.env.get('HOME')) ?? '/tmp'}${path.slice(1)}` : path
154}
155
156/** The data folder: pages, edits and their git history. */
157function dataRoot($: EngineInterface): Promise<string> {
158 return expandHome($, config.dataDir)
159}
160
161/** A JSON POST to the editor on port `p`; it rejects when the editor cannot be reached. */
162function postJson($: EngineInterface, p: number, path: string, body: string) {
163 return $.http.fetch(`http://127.0.0.1:${p}${path}`, {
164 method: 'POST',
165 headers: { 'Content-Type': 'application/json' },
166 body,
167 })
168}
169
170async function openInBrowser($: EngineInterface, target: string): Promise<boolean> {
171 for (const opener of OPENERS) {
172 const ran = await $.process.run([...opener, target], { timeoutMs: 10_000 }).catch(() => undefined)
173 if (ran?.exitCode === 0) return true
174 }
175 return false
176}
177
178/**
179 * Shows an editor page (its path, "/d/<id>") in a tab already open on the editor (editor/viewers.py
180 * picks it): 'shown' when the tab switched to it, 'offered' when the tab has edits in progress and
181 * offers it instead; undefined when no tab took it (or the editor cannot say): open one then.
182 */
183async function showInOpenTab($: EngineInterface, p: number, path: string): Promise<'shown' | 'offered' | undefined> {
184 const res = await postJson($, p, '/api/show', JSON.stringify({ url: path })).catch(() => undefined)
185 if (!res?.ok) return undefined
186 try {
187 const out = JSON.parse(res.text) as { shown?: unknown; offered?: unknown }
188 return out.shown !== true ? undefined : out.offered === true ? 'offered' : 'shown'
189 } catch {
190 return undefined
191 }
192}
193
194// Where the open tab is among many: its icon carries a red dot until it is seen, and the browser's
195// tab search (Ctrl+Shift+A in Chrome and Edge) finds it by its title.
196const FIND_TAB = 'red dot on its tab icon; Ctrl+Shift+A finds it'
197
198/** The toast for what showInOpenTab did with `what` ("2 diagrams", a file's path). */
199function inOpenTab(what: string, outcome: 'shown' | 'offered'): string {
200 return outcome === 'shown'
201 ? `Mermaid: showed ${what} in the open tab (${FIND_TAB})`
202 : `Mermaid: ${what} waiting in the open tab, which has edits in progress (${FIND_TAB})`
203}
204
205/** Tells the person, once, that the editor did not start and why; the static page stands in. */
206function warnNoEditor($: EngineInterface, why: string): void {
207 if (warned) return
208 warned = true
209 $.ui.toast(`Mermaid: the editor did not start (${why.slice(0, 160)}); diagrams open as a static page`)
210}
211
212/** The page server's port, starting it the first time; undefined when it cannot run. */
213function ensureServer($: EngineInterface): Promise<number | undefined> {
214 if (port !== undefined) return Promise.resolve(port)
215 starting ??= new Promise(resolve => {
216 void (async () => {
217 let out = ''
218 let err = '' // the end of what it wrote to stderr: why it did not start, when it did not
219 let listening = false
220 try {
221 const child = $.process.spawn({
222 argv: [
223 'python3',
224 '-I',
225 `${$.plugin.root}/editor/mermaid_view.py`,
226 '--root',
227 await dataRoot($),
228 '--port',
229 String(PORT),
230 '--keep-days',
231 String(config.keepDays),
232 '--migrate-from',
233 await expandHome($, FORMER_ROOT),
234 '--exit-with-parent',
235 ], // the server ends with Claude Code, not a server per session left behind
236 })
237 // The loop is the child's life: it runs on after resolving, until the server or the module ends.
238 for await (const piece of child) {
239 if (listening) continue
240 if (piece.stream === 'stderr') {
241 err = (err + piece.text).slice(-2048)
242 continue
243 }
244 out = (out + piece.text).slice(-2048)
245 const found = /PORT (\d+)/.exec(out)
246 if (found) {
247 listening = true
248 port = Number(found[1])
249 resolve(port)
250 startPolling($)
251 }
252 }
253 if (!listening) warnNoEditor($, err.trim().split('\n').pop() || 'it stopped before listening')
254 } catch {
255 // no python3: the static page stands in
256 warnNoEditor($, 'python3 was not found')
257 } finally {
258 port = undefined
259 starting = undefined
260 resolve(undefined) // no effect once resolved with the port
261 }
262 })()
263 })
264 return starting
265}
266
267// A note sent from a page waits in the server's outbox: put the request in the prompt box.
268function startPolling($: EngineInterface): void {
269 if (polling) return
270 polling = true
271 $.clock.every(POLL_MS, () => void takeOutbox($))
272}
273
274type OutboxItem = {
275 page?: string
276 file?: string
277 project?: string
278 projectName?: string
279 note?: string
280 lang?: string // the page's language (editor/static/i18n.js): 'zh-Hant', else English
281}
282
283/** The request put in the prompt box for a note, in the language of the page that sent it. */
284export function promptFor(item: OutboxItem): string {
285 const zh = item.lang === 'zh-Hant'
286 const text = item.note?.trim()
287 if (item.file) {
288 // The file's project by its id: it need not be the session's (the editor lists every project).
289 const args = item.project ? `file: "${item.file}", project: "${item.project}"` : `file: "${item.file}"`
290 return zh
291 ? `我在 mermaid-view 修改了專案 ${item.projectName ?? ''} 的 ${item.file} 裡的圖${text ? `:${text}` : '。'}請用 ${TOOL_NAME}(${args})讀回修改,比對差異並校正相關的說明。`
292 : `I edited the diagrams in ${item.file} of project ${item.projectName ?? ''} in mermaid-view${text ? `: ${text}` : ''}. Use ${TOOL_NAME} (${args}) to read the edits back, compare the differences and correct the related text.`
293 }
294 return zh
295 ? `我在 mermaid-view 修改了頁面 ${item.page} 的圖${text ? `:${text}` : '。'}請用 ${TOOL_NAME} 讀回修改,比對差異並校正。`
296 : `I edited the diagrams of page ${item.page} in mermaid-view${text ? `: ${text}` : ''}. Use ${TOOL_NAME} to read the edits back, compare the differences and correct.`
297}
298
299// The next note stays the server's next until acknowledged filled: one the box refuses (a dialog
300// holds the keys, no box) is tried again on the next poll, and the tab that sent it hears so.
301async function takeOutbox($: EngineInterface): Promise<void> {
302 if (port === undefined || delivering) return
303 delivering = true // a poll still filling must not hand the same note to the box twice
304 const p = port // the server that hands out the note hears whether it was filled
305 try {
306 const post = (path: string, body: string) => postJson($, p, `/api/outbox/${path}`, body).catch(() => undefined)
307 const res = await post('take', '{}')
308 if (!res?.ok) return
309 const { item } = JSON.parse(res.text) as { item: (OutboxItem & { id: string }) | null }
310 if (!item) return
311 const filled = await $.prompt
312 .fill({ text: promptFor(item), mode: 'append' })
313 .then(r => r.isFilled)
314 .catch(() => false)
315 await post('ack', JSON.stringify({ id: item.id, filled }))
316 if (filled) $.ui.toast('Mermaid: your edits are in the prompt box; press Enter to send')
317 } finally {
318 delivering = false
319 }
320}
321
322// Where the answer was given: the project (its root, named by its last folder), the git branch
323// and the session. Each part is "" when it cannot be told; the branch also on a detached HEAD.
324// `branch --show-current` names a branch with no commits yet, which `rev-parse` cannot.
325async function origin($: EngineInterface) {
326 const root = await $.session.root().catch(() => '')
327 const [ran, session] = await Promise.all([
328 root
329 ? $.process.run(['git', '-C', root, 'branch', '--show-current'], { timeoutMs: 5000 }).catch(() => undefined)
330 : undefined,
331 $.session.id().catch(() => ''),
332 ])
333 return {
334 projectRoot: root,
335 project: root.split('/').filter(Boolean).pop() ?? '',
336 branch: ran?.exitCode === 0 ? ran.stdout.trim() : '',
337 session,
338 }
339}
340
341/** A new page of the editor holding the diagrams (the server writes and commits it); its id, or
342 * undefined when the editor cannot take it. */
343async function createPage(
344 $: EngineInterface,
345 p: number,
346 diagrams: readonly string[],
347 answer: string,
348): Promise<string | undefined> {
349 const body = JSON.stringify({ diagrams, title: titleFor(answer, diagrams), ...(await origin($)) })
350 const res = await postJson($, p, '/api/pages', body).catch(() => undefined)
351 if (!res?.ok) return undefined
352 try {
353 const { id } = JSON.parse(res.text) as { id?: unknown }
354 return typeof id === 'string' ? id : undefined
355 } catch {
356 return undefined
357 }
358}
359
360async function show($: EngineInterface, diagrams: readonly string[], answer = ''): Promise<void> {
361 const p = await ensureServer($)
362 const id = p === undefined ? undefined : await createPage($, p, diagrams, answer)
363 const count = diagrams.length === 1 ? '1 diagram' : `${diagrams.length} diagrams`
364 let target: string
365 let outcome: 'shown' | 'offered' | undefined
366 if (p !== undefined && id !== undefined) {
367 target = `http://localhost:${p}/d/${id}`
368 outcome = config.openAnswers && config.reuseTab ? await showInOpenTab($, p, `/d/${id}`) : undefined
369 } else {
370 const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '')
371 target = `${await dataRoot($)}/diagram-${stamp}.html`
372 await $.fs.write(target, page(diagrams, `Mermaid ${stamp}`))
373 }
374 lastPage = target
375 if (!config.openAnswers) {
376 // Kept, not shown: the person opens them when they choose.
377 $.ui.toast(`Mermaid: ${count} kept; /mermaid opens ${diagrams.length === 1 ? 'it' : 'them'}`)
378 return
379 }
380 if (outcome) {
381 $.ui.toast(inOpenTab(count, outcome))
382 return
383 }
384 const opened = await openInBrowser($, target)
385 $.ui.toast(opened ? `Mermaid: opened ${count} in the browser` : `Mermaid: ${count} at ${target}`)
386}
387
388/** The session's project, registered with the editor once; undefined without a project or editor. */
389function sessionProject($: EngineInterface): Promise<ProjectInfo | undefined> {
390 project ??= (async () => {
391 const p = await ensureServer($)
392 const root = await $.session.root().catch(() => '')
393 if (p === undefined || !root) return undefined
394 const res = await postJson($, p, '/api/projects', JSON.stringify({ root })).catch(() => undefined)
395 return res?.ok ? (JSON.parse(res.text) as ProjectInfo) : undefined
396 })()
397 return project
398}
399
400/** The editor's own page for a session with nothing shown yet: the session's project files, else
401 * its pages (the latest one); undefined when the editor cannot run. */
402async function editorHome($: EngineInterface): Promise<string | undefined> {
403 const info = await sessionProject($)
404 const p = await ensureServer($)
405 return p === undefined ? undefined : `http://localhost:${p}${info ? `/f/${info.id}` : '/d'}`
406}
407
408/**
409 * What /mermaid shows: the last page, at the editor's port now (one that ended starts again, on
410 * another port when its own is taken), or a static page's file as it is; with nothing shown yet,
411 * the editor's own page. Undefined when the editor cannot run.
412 */
413async function lastTarget($: EngineInterface): Promise<string | undefined> {
414 const page = lastPage && /^http:\/\/localhost:\d+(\/.*)$/.exec(lastPage)
415 if (!page) return lastPage ?? editorHome($)
416 const p = await ensureServer($)
417 return p === undefined ? undefined : `http://localhost:${p}${page[1]}`
418}
419
420/** A path inside the project, relative to its root; undefined outside it. */
421export function projectPath(root: string, path: string): string | undefined {
422 const base = root.endsWith('/') ? root : `${root}/`
423 return path.startsWith(base) && path.length > base.length ? path.slice(base.length) : undefined
424}
425
426/**
427 * Claude wrote a project file: when it holds diagrams, the editor keeps what Claude wrote (the
428 * base its later edits compare with) and, the first time this session, opens it.
429 */
430async function noteWrite($: EngineInterface, path: string): Promise<void> {
431 if (!DIAGRAM_FILE.test(path)) return
432 const info = await sessionProject($)
433 const rel = info && projectPath(info.root, path)
434 if (!info || !rel || port === undefined) return
435 const text = await $.fs.read(path)
436 if (!/\.(mmd|mermaid)$/i.test(path) && !mermaidFences(text).length) return
437 // Not caught: an editor that cannot be reached fails the note, which its caller lets pass.
438 const res = await postJson(
439 $,
440 port,
441 `/api/projects/${info.id}/seen?path=${encodeURIComponent(rel)}`,
442 JSON.stringify({ text }),
443 )
444 if (!res.ok || !(JSON.parse(res.text) as { diagrams: number }).diagrams) return
445 const pagePath = `/f/${info.id}?path=${encodeURIComponent(rel)}`
446 const url = `http://localhost:${port}${pagePath}`
447 lastPage = url
448 if (!config.openFiles || openedFiles.has(rel)) return // an open page follows the file by itself
449 openedFiles.add(rel)
450 const outcome = config.reuseTab ? await showInOpenTab($, port, pagePath) : undefined
451 if (outcome) {
452 $.ui.toast(inOpenTab(rel, outcome))
453 return
454 }
455 const opened = await openInBrowser($, url)
456 $.ui.toast(opened ? `Mermaid: opened ${rel}` : `Mermaid: ${rel} at ${url}`)
457}
458
459/** The report of an editor reply asked for with report=1: the text the model reads, rendered by the
460 * editor (editor/mcp_tools.py) for this mod's tools. */
461function reportOf(text: string): string {
462 return (JSON.parse(text) as { report: string }).report
463}
464
465async function readFileEdits($: EngineInterface, file: string, projectId: string): Promise<string> {
466 if (projectId) {
467 // A note from the editor names the file's project, which need not be the session's.
468 const p = await ensureServer($)
469 if (p === undefined) return `${NO_SERVER}, so no edits can be read.`
470 return fileEdits($, p, projectId, file)
471 }
472 const info = await sessionProject($)
473 if (!info || port === undefined)
474 return 'The mermaid-view editor has no project for this session (python3 missing, or no project folder).'
475 const rel = file.startsWith('/') ? projectPath(info.root, file) : file
476 if (!rel) return `${file} is not inside the project ${info.root}.`
477 return fileEdits($, port, info.id, rel)
478}
479
480async function fileEdits($: EngineInterface, p: number, projectId: string, rel: string): Promise<string> {
481 const res = await $.http.fetch(
482 `http://127.0.0.1:${p}/api/projects/${encodeURIComponent(projectId)}/file?${new URLSearchParams({ path: rel, report: '1' })}`,
483 )
484 if (!res.ok) return `No document or diagram file ${rel} in the project.`
485 return reportOf(res.text)
486}
487
488async function readEdits($: EngineInterface, pageId: string | undefined): Promise<string> {
489 const p = await ensureServer($)
490 if (p === undefined) return `${NO_SERVER}, so no edits can be read.`
491 const base = `http://127.0.0.1:${p}/api/pages`
492 let id = pageId
493 if (!id) {
494 // This session's newest page: another session's, newer or not, is not the one the person means.
495 const session = await $.session.id().catch(() => '')
496 const list = await $.http.fetch(base)
497 id = session
498 ? (JSON.parse(list.text) as { pages: { id: string; session?: string }[] }).pages.find(p => p.session === session)
499 ?.id
500 : undefined
501 if (!id) return `No mermaid-view page of this session yet; give a page id (find one with ${SEARCH_NAME}).`
502 }
503 const res = await $.http.fetch(`${base}/${encodeURIComponent(id)}?report=1`)
504 if (!res.ok) return `No mermaid-view page "${id}".`
505 return reportOf(res.text)
506}
507
508async function search($: EngineInterface, query: string, project: string): Promise<string> {
509 const p = await ensureServer($)
510 if (p === undefined) return `${NO_SERVER}, so pages cannot be searched.`
511 // The project files are looked up alongside the pages, but awaited only once the pages are in,
512 // so a failed page search answers first (and leaves the lookup's own failure unheard).
513 const files = searchFiles($, p, query, project)
514 files.catch(() => undefined)
515 const params = new URLSearchParams({ q: query, project, report: '1' })
516 const res = await $.http.fetch(`http://127.0.0.1:${p}/api/pages?${params}`)
517 if (!res.ok) return `The search failed (${res.status}).`
518 const pages = reportOf(res.text)
519 const report = await files
520 return report ? `${pages}\n\n${report}` : pages
521}
522
523/** The session's project files of a search, when it is not about another project; undefined when none match. */
524async function searchFiles($: EngineInterface, p: number, query: string, project: string): Promise<string | undefined> {
525 const info = await sessionProject($)
526 if (!info || (project && project !== info.name)) return undefined
527 const found = await $.http
528 .fetch(`http://127.0.0.1:${p}/api/projects/${info.id}/files?${new URLSearchParams({ q: query, report: '1' })}`)
529 .catch(() => undefined)
530 if (!found?.ok) return undefined
531 const { files, report } = JSON.parse(found.text) as { files: unknown[]; report: string }
532 return files.length ? report : undefined
533}
534
535export const register: Register = (on, options) => {
536 const given = options as Partial<Options> | undefined
537 config = {
538 dataDir: typeof given?.dataDir === 'string' && given.dataDir.trim() ? given.dataDir.trim() : config.dataDir,
539 keepDays: typeof given?.keepDays === 'number' && given.keepDays >= 0 ? given.keepDays : config.keepDays,
540 openAnswers: typeof given?.openAnswers === 'boolean' ? given.openAnswers : config.openAnswers,
541 openFiles: typeof given?.openFiles === 'boolean' ? given.openFiles : config.openFiles,
542 reuseTab: typeof given?.reuseTab === 'boolean' ? given.reuseTab : config.reuseTab,
543 }
544
545 on('session.start', async ($, e, next) => {
546 await $.command.register({
547 name: 'mermaid',
548 description: 'Open the Mermaid editor on the last diagrams, else on the project files',
549 })
550 await $.tool.register({
551 name: TOOL,
552 description:
553 "Reads the person's edits to Mermaid diagrams made in the mermaid-view browser editor: those of an " +
554 "earlier answer's page (per edited diagram a unified diff against the source you wrote, and the " +
555 'edited source), or those of a project file, a Markdown document or .mmd file (per diagram what ' +
556 'changed since you last wrote the file, else since the last commit). Use it when the person says ' +
557 'they edited or adjusted a diagram, then compare and correct.',
558 inputSchema: {
559 type: 'object',
560 properties: {
561 page: {
562 type: 'string',
563 description: "The page id; this session's latest page when neither this nor file is given.",
564 },
565 file: {
566 type: 'string',
567 description: "A project file's path, relative to the project root or absolute, e.g. docs/architecture.md.",
568 },
569 project: {
570 type: 'string',
571 description:
572 "With file: the id of the registered project the file belongs to, as the person's note names it (then the path is relative to that project's root); the session's project when omitted.",
573 },
574 },
575 },
576 isDeferred: false,
577 })
578 await $.tool.register({
579 name: SEARCH,
580 description:
581 'Finds Mermaid diagrams of earlier answers kept by mermaid-view, across sessions and projects: ' +
582 'by words in their title, tags, project or source (every word must match), optionally within ' +
583 'one project (the folder name of its root). Lists each page id with its project, branch, title ' +
584 'and matching source lines, newest first. Use it when the person refers to an earlier diagram.',
585 inputSchema: {
586 type: 'object',
587 properties: {
588 query: { type: 'string', description: 'Words to find; empty lists the newest pages.' },
589 project: { type: 'string', description: 'Only this project, e.g. "realtime-voice-agent".' },
590 },
591 },
592 isDeferred: false,
593 })
594 // The editor and the project, started without holding up the session.
595 void sessionProject($).catch(() => undefined)
596 return next(e)
597 })
598
599 // Claude wrote a file: a project document or diagram file with diagrams opens in the editor. A
600 // write the person refused, or one that failed, left the file as it was: it is not what Claude
601 // wrote, and noting it would hide the person's own edits from diagram_edits.
602 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
603 const result = await next(e)
604 if (result.deny === undefined && !result.isError) void noteWrite($, e.file_path).catch(() => undefined)
605 return result
606 }).catch(($, e, next) => next(e)) // never in the way of the write itself
607
608 on('tool.call', { tool: SEARCH_NAME }, async ($, e) => {
609 const query = e['query']
610 const project = e['project']
611 return {
612 result: await search($, typeof query === 'string' ? query : '', typeof project === 'string' ? project : ''),
613 }
614 }).catch(($, e, next) =>
615 next.called
616 ? next(e)
617 : { result: `mermaid-view: the search failed (${String(next.error?.message ?? next.error)}).` },
618 )
619
620 on('tool.call', { tool: TOOL_NAME }, async ($, e) => {
621 const file = e['file']
622 const project = e['project']
623 if (typeof file === 'string' && file.trim()) {
624 return { result: await readFileEdits($, file.trim(), typeof project === 'string' ? project.trim() : '') }
625 }
626 const pageId = e['page']
627 return { result: await readEdits($, typeof pageId === 'string' ? pageId : undefined) }
628 }).catch(($, e, next) =>
629 next.called
630 ? next(e)
631 : { result: `mermaid-view: reading the edits failed (${String(next.error?.message ?? next.error)}).` },
632 )
633
634 on('command.run', { command: 'mermaid' }, async $ => {
635 const target = await lastTarget($)
636 if (!target) return { text: NO_SERVER }
637 const p = port
638 const origin = `http://localhost:${p}`
639 const outcome =
640 config.reuseTab && p !== undefined && target.startsWith(`${origin}/`)
641 ? await showInOpenTab($, p, target.slice(origin.length))
642 : undefined
643 if (outcome === 'shown') return { text: `Showed ${target} in the open tab` }
644 if (outcome === 'offered') return { text: `${target} is waiting in the open tab (it has edits in progress)` }
645 const opened = await openInBrowser($, target)
646 return { text: opened ? `Opened ${target}` : `Could not open a browser; the page is ${target}` }
647 })
648
649 on('turn.complete', async ($, e, next) => {
650 const result = await next(e)
651 // The main conversation's answers only: a subagent's report is not shown as written.
652 if (e.agentId === undefined && e.reason === 'answer') {
653 const diagrams = mermaidBlocks(e.answer)
654 if (diagrams.length) void show($, diagrams, e.answer).catch(err => $.ui.toast(`Mermaid: ${String(err)}`))
655 }
656 return result
657 })
658}
659