Renders mermaid code blocks inline as ASCII art (beautiful-mermaid, termaid) or images with an open-full-size button; /mermaid switches modes; mermaid.ink is…

Renders ``` `mermaid ``` blocks from the conversation inline — the chart sits with the response that produced it. No pane, no state: two render modes, an optional remote fallback, and a mode-setting command.
| Command | What it does |
|---|---|
/mermaid ascii | Rewrite each mermaid fence in place as rendered ASCII art |
/mermaid image | Keep the source; draw each chart as a real terminal PNG (local mermaid-cli first) |
/mermaid external on | Opt in to mermaid.ink when local PNG fails (sends full diagram source; persists) |
/mermaid external off | Revoke remote rendering (default; persists). Image mode stays local-only |
/mermaid | Report the current mode, external setting, and renderer availability |
/mermaid setup | Install missing optional renderers (background, logged) |
Mode and the external opt-in are remembered across sessions (durable store). Image mode alone is not consent for remote rendering.
Default: OFF for fresh installs and upgrades. A previously saved image mode does not enable mermaid.ink.
When external is ON and local mermaid-cli is missing, fails, times out, or produces unusable PNG metadata, the mod may request a PNG (and a small SVG sizing probe) from mermaid.ink. Those requests encode the full normalized diagram source in the URL. Revoke anytime with /mermaid external off — permission is re-checked immediately before every network send (including pre-warm and retries).
ASCII mode never leaves the machine. Diagnostics never include diagram source, encoded URLs, or raw commands that embed them.
| Renderer | What it improves | Install via /mermaid setup |
|---|---|---|
beautiful-mermaid | Most faithful ASCII art (labels and line breaks intact) for flowchart, sequence, state, class, ER and XY charts; graph art that drops a node (1.1.3 misreads unspaced arrows like A-->B) falls back to termaid | bun add --exact beautiful-mermaid@1.1.3 (else npm install) into ~/.local/share/mermaid-pane/beautiful-mermaid, with a small runner; run by bun, else node |
termaid | Better ASCII art across many Mermaid diagram types (flowchart, sequence, class, ER, state, gantt, mindmap, …) vs the built-in edge list | pip install --user termaid (Python ≥ 3.9); console script linked into ~/.local/bin |
@mermaid-js/mermaid-cli (mmdc) | Offline PNG rendering (puppeteer fetches a prebuilt Chromium on first render) | npm install -g @mermaid-js/mermaid-cli, pinned to the Node major found (12.x needs ≥ 22.13, 11.x ≥ 18.19); nvm PATH handled |
Without them the mod still works everywhere: edge-list art (built in, always fits, never clips) for ascii, and — only if you /mermaid external on — mermaid.ink + curl + sips for images. /mermaid setup detects what's missing, starts the installs detached (so the render hook never blocks on pip/npm), logs to ~/.cache/mermaid-pane/setup.log, and a re-run reports progress and starts using what finished installing.
--width, which compacts gaps itself) → built-in edge-list art when termaid's art is still wider than the reply → the source itself; text wraps, never clips. Always local.mmdc -s 2 (bounded duration, no network) → optional mermaid.ink (opt-in) → sips → PNG, drawn as a native Image sized from the SVG's own viewBox when remote sizing is allowed; otherwise a fixed native width. Falls back to ascii art with a short actionable note when a PNG can't be produced; a failed chart is retried after a minute, and overlapping redraws share one render. Each image has a ⤢ open full size button under it (click in the fullscreen terminal, or Enter when focused) that opens the 2× PNG in the system viewer (macOS open) for zoom. Real pixels need a terminal Claude Code can paint images in (kitty, Ghostty); other terminals — including iTerm2/WezTerm — draw the alt-text art instead.claude plugin validate ./mermaid-pane
claude plugin test ./mermaid-pane
claude --plugin-dir ./mermaid-panehooks/mermaid-pane.mjs 965 lines1// mermaid-pane — renders ```mermaid blocks from the conversation inline, as
2// ASCII art or images, per the user's pick (see research/mermaid-in-tui.md).
3//
4// Modes (via /mermaid ascii | /mermaid image; picked per session in $.state,
5// remembered across sessions in $.store):
6// ascii — each reply's mermaid fence is rewritten in place into rendered
7// ASCII art (the chart sits with the response it belongs to)
8// image — replies keep their source; each chart draws as a real diagram
9// PNG, degrading to art where the terminal cannot draw images
10//
11// External rendering (mermaid.ink) is OFF by default — image mode alone is
12// not consent. Opt in with /mermaid external on (persists in $.store). Local
13// mmdc is preferred; on miss/fail/timeout with external OFF, fail closed to
14// ASCII with a short actionable diagnostic (never logs diagram source or
15// encoded URLs).
16//
17// No-cut strategy (art): termaid (--width + compact gaps) → built-in
18// edge-list art → the source itself; every Text draws with wrap:'wrap',
19// lines wrap, never clip. PNG path: local mmdc → (opt-in) mermaid.ink → sips.
20
21const MODE = { plugin: "mermaid-pane", key: "mode" }; // read while drawing: a set redraws the drawer
22// Rendered PNGs, .mmd sources and install scripts live in a per-user dir,
23// "$HOME/.cache/mermaid-pane" — never shared /tmp, where another local user
24// could create the dir first (plant a puppeteer.json whose executablePath
25// runs, swap an install script, or just block rendering). Resolved once.
26let pngDirMemo; // undefined = not resolved this load
27
28async function pngDir($) {
29 if (pngDirMemo) return pngDirMemo;
30 const r = await run($, `echo "home:$HOME"`); // a cut throws: caller treats it as cut
31 const home = /^home:(\/.+)$/m.exec(r.stdout ?? "")?.[1];
32 if (!home) return null;
33 pngDirMemo = `${home}/.cache/mermaid-pane`;
34 return pngDirMemo;
35}
36
37// Defence in depth: use the dir only once it is ours, not a symlink, private.
38function dirGuardSh(dir) {
39 const d = shellQuote(dir);
40 return (
41 `umask 077; mkdir -p ${d} 2>/dev/null; ` +
42 `{ [ ! -L ${d} ] && [ -O ${d} ] && chmod 700 ${d}; } || ` +
43 `{ echo "mermaid-pane: ${dir.replace(/[^\w./-]/g, "?")} is not this user's; not using it" >&2; exit 97; }`
44 );
45}
46
47// Every command starts in / — never the session's directory, a repo that may
48// be untrusted: bun reads its bunfig.toml (a `preload` runs code), puppeteer
49// its .puppeteerrc.cjs. All paths the mod passes are absolute.
50const SAFE_CWD = "/";
51const LOCAL_RENDER_SECS = 45; // bound local mmdc so a hung Chromium cannot stall redraws
52const RETRY_MS = 60000; // a failed chart renders again after this long
53
54// Module-level memos: survive redraws, reset on hot reload (fine for caches).
55const artCache = new Map(); // `${budget}|${code}` -> art string
56const pngCache = new Map(); // base -> { file, w, h, native } (successes only)
57const pngFailed = new Map(); // base -> { at, diag } of the last failure (retry window)
58const pngInflight = new Map(); // base -> the render in flight (shared by overlapping draws)
59const pngAwaited = new Set(); // bases a draw stopped waiting for: redraw when they land
60let pngsDirty = false; // a PNG was produced after some row may have drawn without it
61let probedTool; // undefined = not probed this load
62let lastPngDiag = null; // last safe diagnostic for /mermaid status (no source/URLs)
63
64// --- tiny utils (sandbox: no Node, no btoa) ---------------------------------
65
66const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
67
68function b64url(str) {
69 const bytes = [...unescape(encodeURIComponent(str))].map((c) => c.charCodeAt(0));
70 let out = "";
71 for (let i = 0; i < bytes.length; ) {
72 const b1 = bytes[i++];
73 const b2 = i < bytes.length ? bytes[i++] : NaN;
74 const b3 = i < bytes.length ? bytes[i++] : NaN;
75 out += B64[b1 >> 2];
76 out += B64[((b1 & 3) << 4) | (isNaN(b2) ? 0 : b2 >> 4)];
77 out += isNaN(b2) ? "" : B64[((b2 & 15) << 2) | (isNaN(b3) ? 0 : b3 >> 6)];
78 out += isNaN(b3) ? "" : B64[b3 & 63];
79 }
80 return out;
81}
82
83function inkUrl(code, kind = "svg") {
84 const payload = b64url(JSON.stringify({ code, mermaid: { theme: "default" } }));
85 return `https://mermaid.ink/${kind}/${payload}`;
86}
87
88function hash(s) {
89 let h = 5381;
90 for (const c of s) h = ((h << 5) + h + c.charCodeAt(0)) | 0;
91 return (h >>> 0).toString(36);
92}
93
94function shellQuote(s) {
95 return `'${String(s).replace(/'/g, `'\\''`)}'`;
96}
97
98let modeMemo; // undefined = not loaded this load; render path must stay sync-fast
99let externalMemo; // undefined = not loaded; "on" | "off"
100
101async function getMode($) {
102 if (modeMemo === undefined) {
103 try {
104 const { value } = await $.state.get(MODE); // this session's pick; subscribes the drawer
105 if (value === "image" || value === "ascii") {
106 modeMemo = value;
107 return modeMemo;
108 }
109 } catch {
110 // fall through to the durable store
111 }
112 try {
113 const remembered = await $.store.get("mode"); // durable across sessions and reloads
114 modeMemo = remembered === "image" ? "image" : "ascii";
115 } catch {
116 modeMemo = "ascii";
117 }
118 }
119 return modeMemo;
120}
121
122// External mermaid.ink is opt-in and durable. Default OFF — a saved image mode
123// never implies consent. Check immediately before every network send.
124async function getExternalAllowed($) {
125 if (externalMemo === undefined) {
126 try {
127 const remembered = await $.store.get("external");
128 externalMemo = remembered === "on" ? "on" : "off";
129 } catch {
130 externalMemo = "off";
131 }
132 }
133 return externalMemo === "on";
134}
135
136function clearImageCaches() {
137 pngCache.clear();
138 svgCache.clear();
139 pngFailed.clear();
140 pngsDirty = false;
141 lastPngDiag = null;
142}
143
144async function setExternalAllowed($, allowed) {
145 externalMemo = allowed ? "on" : "off";
146 clearImageCaches(); // revocation/opt-in must affect queued retries and redraws
147 await $.store.set("external", externalMemo);
148}
149
150async function run($, cmd, init) {
151 const r = await $.process.run(["/bin/sh", "-c", cmd], { cwd: SAFE_CWD, ...init });
152 return r?.value ?? r ?? {};
153}
154
155// Hook shells don't source rc files, so nvm's node bin dir is often missing
156// from PATH; prepend the newest installed nvm node when probing/installing.
157const NVM_PATH_PRELUDE =
158 'NB=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | tail -1); [ -n "$NB" ] && export PATH="$NB:$PATH"; ';
159
160// Locate a binary: ambient PATH first, then ~/.local/bin (the setup target).
161// Everything env-sensitive stays in $HOME/uname — nothing per-machine hardcoded.
162function whichSh(bin, prelude = "") {
163 return (
164 `${prelude}if command -v ${bin} >/dev/null 2>&1; then command -v ${bin};` +
165 ` elif [ -x "$HOME/.local/bin/${bin}" ]; then echo "$HOME/.local/bin/${bin}"; fi`
166 );
167}
168
169// The path, null when absent, undefined when the probe was cut (not an answer).
170async function probeSh($, sh) {
171 try {
172 const { exitCode, stdout } = await run($, sh);
173 return (exitCode ?? 1) === 0 && stdout?.trim() ? stdout.trim() : null;
174 } catch (err) {
175 return isAborted(err) ? undefined : null;
176 }
177}
178
179async function probeTool($) {
180 if (probedTool !== undefined) return probedTool;
181 probedTool = await probeSh($, whichSh("termaid"));
182 return probedTool;
183}
184
185// --- tier 1: beautiful-mermaid, then termaid (Mermaid → Unicode art) ---------
186
187// beautiful-mermaid (npm) is installed by /mermaid setup into its own folder,
188// pinned, with a tiny runner; bun runs it when present, else node.
189const BM_VERSION = "1.1.3";
190const BM_DIR = "$HOME/.local/share/mermaid-pane/beautiful-mermaid"; // expanded by sh
191const BM_TYPES = /^(flowchart|graph|stateDiagram(-v2)?|sequenceDiagram|classDiagram|erDiagram|xychart(-beta)?)\b/;
192const RUNTIME_SH =
193 `if command -v bun >/dev/null 2>&1; then command -v bun; ` +
194 `elif [ -x "$HOME/.bun/bin/bun" ]; then echo "$HOME/.bun/bin/bun"; ` +
195 `else ${NVM_PATH_PRELUDE}command -v node; fi`;
196const BM_PROBE_SH =
197 `D="${BM_DIR}"; [ -f "$D/render.mjs" ] && [ -f "$D/node_modules/beautiful-mermaid/package.json" ] || exit 1; ${RUNTIME_SH}`;
198let probedBm; // undefined = not probed this load
199
200async function probeBm($) {
201 if (probedBm !== undefined) return probedBm;
202 probedBm = await probeSh($, BM_PROBE_SH);
203 return probedBm;
204}
205
206function artWidth(art) {
207 return Math.max(0, ...art.split("\n").map((l) => l.length));
208}
209
210// Run one art renderer: its art when it exits 0 and fits the budget, else
211// null; `cut` when the host cut the run (no answer, so nothing is cached).
212async function artFrom($, sh, budget) {
213 try {
214 const { exitCode, stdout } = await run($, sh);
215 if ((exitCode ?? 1) !== 0 || !stdout?.trim()) return { art: null, cut: false };
216 const art = stdout.replace(/\n+$/, "");
217 return { art: artWidth(art) <= budget ? art : null, cut: false };
218 } catch (err) {
219 return { art: null, cut: isAborted(err) };
220 }
221}
222
223// beautiful-mermaid 1.1.3 reads an arrow written without spaces (`A-->B`) as
224// one box "A--" and still exits 0. Graph art is accepted only when it shows
225// every node an edge names: its label's first word, else its id.
226function showsEveryNode(code, art) {
227 if (!/^(flowchart|graph|stateDiagram)/.test(code)) return true;
228 const flat = code.replace(/<br\s*\/?>/gi, " ");
229 const labels = {};
230 for (const m of flat.matchAll(/([A-Za-z]\w*)\s*[\[\(\{]+"?([^\]\)\}"]+)"?[\]\)\}]+/g)) labels[m[1]] = m[2].trim();
231 const shown = (word) => new RegExp(`(^|[^A-Za-z0-9_])${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9_]|$)`, "m").test(art);
232 for (const line of normalize(flat).split("\n").slice(1)) {
233 const segs = line.split(/-->>|-->|===|---|-\.->|->>|==>|--x|--o/);
234 if (segs.length < 2) continue;
235 for (const seg of segs) {
236 const id = /^\s*(?:\|[^|]*\|)?\s*([A-Za-z]\w*)/.exec(seg)?.[1];
237 if (!id) continue;
238 const word = (labels[id] ?? id).split(/\s+/)[0];
239 if (word && !shown(word)) return false;
240 }
241 }
242 return true;
243}
244
245async function asciiFor($, code, budget) {
246 code = normalize(code);
247 const cacheKey = `${budget}|${code}`;
248 if (artCache.has(cacheKey)) return artCache.get(cacheKey);
249 let cut = false; // a cut probe or run is no answer: don't cache
250 let art = null;
251 // beautiful-mermaid keeps every label but draws only some types and takes
252 // no width, so art wider than the reply falls through to termaid.
253 if (BM_TYPES.test(code)) {
254 const runtime = await probeBm($);
255 cut ||= runtime === undefined;
256 if (runtime) {
257 const r = await artFrom($, `printf '%s' ${shellQuote(code)} | ${shellQuote(runtime)} "${BM_DIR}/render.mjs" 2>/dev/null`, budget);
258 art = r.art && showsEveryNode(code, r.art) ? r.art : null;
259 cut ||= r.cut;
260 }
261 }
262 if (!art) {
263 const tool = await probeTool($);
264 cut ||= tool === undefined;
265 if (tool) {
266 // termaid already re-renders with smaller gaps and padding to fit --width;
267 // what still overflows is as compact as it gets, so edge art takes over.
268 const r = await artFrom($, `printf '%s' ${shellQuote(code)} | ${shellQuote(tool)} --width ${budget} 2>/dev/null`, budget);
269 art = r.art;
270 cut ||= r.cut;
271 }
272 }
273 const result = art ?? edgeArt(code) ?? code; // full code, wrapped — never cut
274 if (!cut) artCache.set(cacheKey, result);
275 return result;
276}
277
278// --- tier 2: built-in edge-list art (always fits; lines wrap, never cut) -----
279
280// Normalize: the header ("flowchart LR") must open its own line for termaid
281// and for line-based edge parsing; replies often inline it.
282function normalize(code) {
283 return code.replace(/^(flowchart|graph)\s+(TD|TB|LR|RL|BT)\b[: ]*/i, "$1 $2\n").trim();
284}
285
286function edgeArt(code) {
287 code = code.replace(/<br\s*\/?>/gi, " "); // one edge per line: label breaks become spaces
288 const labels = {};
289 for (const m of code.matchAll(/([A-Za-z]\w*)\s*[\[\(\{]+([^\]\)\}]+)[\]\)\}]+/g)) {
290 labels[m[1]] = m[2].trim();
291 }
292 const name = (id) => (labels[id] ? `${id}[${labels[id]}]` : id);
293 const glyph = (arr) => ({ "-->" : "──", "===" : "══", "---" : "──", "-.->" : "┄┄", "->>" : "──", "-->>" : "┄┄" }[arr] ?? "──");
294 const idOf = (tok) => /^\s*([A-Za-z]\w*)/.exec(tok)?.[1] ?? null;
295 const lines = [];
296 for (let raw of normalize(code).split("\n")) {
297 const line = raw.trim();
298 if (!line || /^(subgraph|end$|participant|title|%%)/i.test(line)) continue;
299 // Tokenize chains (A --> B --> C) and sequence arrows (A->>B: msg) alike.
300 const segs = line.split(/(-->>|-->|===|---|-\.->|->>)/);
301 if (segs.length < 3) continue;
302 for (let i = 1; i < segs.length - 1; i += 2) {
303 const arr = segs[i];
304 let after = segs[i + 1];
305 let lbl = null;
306 let extra = null;
307 const pipe = /^\s*\|([^|]*)\|\s*/.exec(after);
308 if (pipe) {
309 lbl = pipe[1].trim();
310 after = after.slice(pipe[0].length);
311 }
312 const colon = /^\s*([A-Za-z]\w*)\s*:\s*(.+)$/.exec(after);
313 if (colon) {
314 extra = colon[2].trim();
315 after = colon[1];
316 }
317 const a = idOf(segs[i - 1]);
318 const b = idOf(after);
319 if (!a || !b) continue;
320 const tag = extra ? `: ${extra}` : lbl ? ` |${lbl}|` : "";
321 lines.push(`${name(a)} ${glyph(arr)}▶ ${name(b)}${tag}`);
322 }
323 }
324 return lines.length ? lines.join("\n") : null;
325}
326
327// --- image mode: local mmdc render (no network) → opt-in mermaid.ink ------
328
329let probedMmdc; // undefined = not probed this load
330
331async function mmdcPath($) {
332 if (probedMmdc !== undefined) return probedMmdc;
333 probedMmdc = await probeSh($, whichSh("mmdc", NVM_PATH_PRELUDE));
334 return probedMmdc;
335}
336
337// Safe, source-free reason strings for the user. Never include diagram text,
338// encoded payloads, mermaid.ink URLs, or raw shell commands that embed them.
339function diagLocal(kind) {
340 const remoteHint =
341 "Remote mermaid.ink is off — /mermaid external on to allow (sends full diagram source; persists).";
342 switch (kind) {
343 case "missing":
344 return `Local PNG unavailable (mermaid-cli not found). ASCII fallback. ${remoteHint}`;
345 case "failed":
346 return `Local PNG render failed. ASCII fallback. ${remoteHint}`;
347 case "timeout":
348 return `Local PNG render timed out. ASCII fallback. ${remoteHint}`;
349 case "meta":
350 return `Local PNG unreadable (bad image metadata). ASCII fallback. ${remoteHint}`;
351 case "remote-failed":
352 return "Remote PNG unavailable. ASCII fallback. /mermaid setup installs local mermaid-cli.";
353 default:
354 return `Local PNG unavailable. ASCII fallback. ${remoteHint}`;
355 }
356}
357
358// A superseded draw's in-flight command is cut by the host ("… aborted").
359// That is not a render failure: nothing is recorded, a later draw renders.
360// The host words its own timeoutMs kill "aborted: still running after …ms";
361// that one is a real timeout.
362const ABORTED = Symbol("aborted");
363
364function isRunTimeout(err) {
365 return /still running after/i.test(String(err?.message ?? err));
366}
367
368function isAborted(err, signal) {
369 if (isRunTimeout(err)) return false;
370 return Boolean(signal?.aborted) || err?.name === "AbortError" || /\baborted\b/i.test(String(err?.message ?? err));
371}
372
373// Waiting on another draw's render is not a $ call, so this hook's budget
374// runs on through it: stop short of the budget (or at this draw's abort).
375const GAVE_UP = Symbol("gave-up");
376const WAIT_MARGIN_MS = 2000;
377
378async function awaitShared($, pending, next) {
379 const remaining = next?.budget?.remainingMs;
380 const ms = (Number.isFinite(remaining) ? remaining : 8000 + WAIT_MARGIN_MS) - WAIT_MARGIN_MS;
381 if (ms <= 0 || next?.signal?.aborted) return GAVE_UP;
382 const stop = new AbortController();
383 const cut = () => stop.abort();
384 next?.signal?.addEventListener?.("abort", cut);
385 try {
386 const timer = $.clock.sleep(ms, { signal: stop.signal }).then(
387 () => GAVE_UP,
388 () => GAVE_UP
389 );
390 return await Promise.race([pending, timer]);
391 } finally {
392 stop.abort();
393 next?.signal?.removeEventListener?.("abort", cut);
394 }
395}
396
397// Overlapping draws of one chart (several redraws, the turn-end pre-warm)
398// share the render in flight instead of racing their own mmdc runs.
399async function ensurePng($, code, next) {
400 code = normalize(code);
401 const base = `d${hash(code)}`;
402 const signal = next?.signal;
403 for (;;) {
404 if (pngCache.has(base)) return pngCache.get(base);
405 const pending = pngInflight.get(base);
406 if (!pending) break;
407 const shared = await awaitShared($, pending, next);
408 if (shared === GAVE_UP) {
409 pngAwaited.add(base); // draw the fallback now; redraw when it lands
410 return null;
411 }
412 if (shared !== ABORTED) return shared;
413 // the draw that owned it was cut: this one renders for itself
414 }
415 const own = renderPng($, code, base, signal).finally(() => pngInflight.delete(base));
416 pngInflight.set(base, own);
417 const result = await own;
418 return result === ABORTED ? null : result;
419}
420
421// The note a chart's ASCII fallback carries: its own last failure, if any.
422function pngDiag(code) {
423 return pngFailed.get(`d${hash(normalize(code))}`)?.diag ?? null;
424}
425
426async function renderPng($, code, base, signal) {
427 // Failures retry after a minute — never cached permanently, so missed
428 // charts upgrade on a later draw. Without a clock a failure stands for the
429 // session rather than re-running mmdc on every redraw.
430 let now = 0;
431 try {
432 now = (await $.clock.now()) || 0;
433 } catch {
434 now = 0;
435 }
436 const failed = pngFailed.get(base);
437 if (failed && (!now || now - failed.at < RETRY_MS)) return null;
438 let result = null;
439 let failKind = "failed";
440 let dir;
441 try {
442 dir = await pngDir($);
443 } catch (err) {
444 if (isAborted(err, signal)) return ABORTED;
445 dir = null;
446 }
447 if (!dir) return null; // no private dir: nothing is written anywhere
448 const png = `${dir}/${base}.png`;
449 const q = (p) => shellQuote(p);
450 const mmdc = await mmdcPath($);
451 if (mmdc === undefined) return ABORTED; // the probe was cut
452 if (!mmdc) {
453 failKind = "missing";
454 } else {
455 // Tier 0: local render via mermaid-cli + the system browser — no network.
456 // -s 2 doubles the pixels; the terminal downsamples the crisp source.
457 // Bound duration so a hung Chromium cannot stall the render hook.
458 try {
459 const sh =
460 `${NVM_PATH_PRELUDE}command -v node >/dev/null 2>&1 || exit 0; ` +
461 `${dirGuardSh(dir)}; printf '%s' ${shellQuote(code)} > ${q(`${dir}/${base}.mmd`)} && ` +
462 `P=""; [ -f ${q(`${dir}/puppeteer.json`)} ] && P=${q(`${dir}/puppeteer.json`)}; ` +
463 `[ -f ${q(`${dir}/puppeteer.json`)} ] || for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "/Applications/Chromium.app/Contents/MacOS/Chromium" "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"; do [ -x "$c" ] && printf '{"executablePath":"%s"}' "$c" > ${q(`${dir}/puppeteer.json`)} && P=${q(`${dir}/puppeteer.json`)} && break; done; ` +
464 `if command -v timeout >/dev/null 2>&1; then ` +
465 `timeout ${LOCAL_RENDER_SECS} '${mmdc}' \${P:+-p "$P"} -i ${q(`${dir}/${base}.mmd`)} -o ${q(png)} -b white -s 2 >/dev/null 2>&1; ec=$?; ` +
466 `[ "$ec" -eq 124 ] && exit 124; [ "$ec" -eq 0 ] || exit "$ec"; ` +
467 `else '${mmdc}' \${P:+-p "$P"} -i ${q(`${dir}/${base}.mmd`)} -o ${q(png)} -b white -s 2 >/dev/null 2>&1 || exit $?; fi; ` +
468 `sips -g pixelWidth -g pixelHeight ${q(png)}`;
469 // $.process.run kills at 30 s by default — before `timeout` could report.
470 const { exitCode, stdout } = await run($, sh, { timeoutMs: (LOCAL_RENDER_SECS + 15) * 1000 });
471 if ((exitCode ?? 1) === 124) {
472 failKind = "timeout";
473 } else {
474 const w = /pixelWidth: (\d+)/.exec(stdout ?? "")?.[1];
475 const h = /pixelHeight: (\d+)/.exec(stdout ?? "")?.[1];
476 if ((exitCode ?? 1) === 0 && w && h) {
477 // -s 2: the PNG is the natural layout at 2x — native cell width = w/2/16.
478 const native = Math.max(24, Math.min(100, Math.round(parseInt(w, 10) / 32)));
479 result = { file: png, w: parseInt(w, 10), h: parseInt(h, 10), native };
480 } else if ((exitCode ?? 1) === 0) {
481 failKind = "meta";
482 } else {
483 failKind = "failed";
484 }
485 }
486 } catch (err) {
487 if (isAborted(err, signal)) return ABORTED;
488 failKind = isRunTimeout(err) ? "timeout" : "failed";
489 result = null;
490 }
491 }
492 if (!result) {
493 // Tier 1 fallback: mermaid.ink (network) — only when explicitly opted in.
494 // Re-check permission immediately before sending so revoke stops in-flight work.
495 if (await getExternalAllowed($)) {
496 try {
497 const jpg = `${dir}/${base}.jpg`;
498 const targetPx = 2400;
499 // Permission still holds at the moment of the request construction.
500 if (await getExternalAllowed($)) {
501 const sh =
502 `${dirGuardSh(dir)}; curl -sfL --max-time 25 '${inkUrl(code, "img")}?type=png&width=${targetPx}' -o ${q(jpg)}` +
503 ` && sips -s format png ${q(jpg)} --out ${q(png)} >/dev/null 2>&1` +
504 ` && sips -g pixelWidth -g pixelHeight ${q(png)}`;
505 const { exitCode, stdout } = await run($, sh);
506 const w = /pixelWidth: (\d+)/.exec(stdout ?? "")?.[1];
507 const h = /pixelHeight: (\d+)/.exec(stdout ?? "")?.[1];
508 if ((exitCode ?? 1) === 0 && w && h) {
509 const natural = await naturalCols($, code);
510 result = { file: png, w: parseInt(w, 10), h: parseInt(h, 10), native: natural };
511 } else {
512 failKind = "remote-failed";
513 }
514 }
515 } catch (err) {
516 if (isAborted(err, signal)) return ABORTED;
517 failKind = "remote-failed";
518 result = null;
519 }
520 }
521 }
522 if (result) {
523 // Only successes are cached, so no failure can ever replace one.
524 pngFailed.delete(base);
525 lastPngDiag = null;
526 pngCache.set(base, result);
527 pngsDirty = true; // a row may have drawn without this PNG
528 if (pngAwaited.delete(base)) {
529 try {
530 await $.ui.invalidate("ui.render"); // a draw gave up waiting: upgrade it now
531 } catch {
532 // the turn-end pre-warm redraws it instead
533 }
534 }
535 return result;
536 }
537 lastPngDiag = diagLocal(failKind); // the newest failure, for /mermaid status
538 pngFailed.set(base, { at: now, diag: lastPngDiag });
539 return null;
540}
541
542// Cell grid for an Image. COMFORT_SCALE: mermaid label text is ~8px per char
543// at native size while a terminal char is ~16px, so 1.5x native reads well and
544// stays dense. The box is columns*cellW px wide and rows*cellH px tall, so an
545// undistorted picture needs rows/columns = (png.h/png.w) x (cellW/cellH) —
546// multiply by the cell aspect, never divide. Aspect-fits into BOTH bounds:
547// tall diagrams (class/ER) shrink to the height cap instead of distorting.
548const COMFORT_SCALE = 1.5;
549const CELL_ASPECT = 16 / 34; // herdr cell: 16px wide x 34px tall
550
551function sizePng(png, maxColumns, maxRows) {
552 const target = Number.isFinite(png.native) && png.native > 0 ? png.native : FALLBACK_NATIVE_COLS;
553 const rowsPerCol = (png.h / png.w) * CELL_ASPECT;
554 let columns = Math.max(20, Math.floor(Math.min(target * COMFORT_SCALE, maxColumns)));
555 let rows = Math.max(1, Math.round(columns * rowsPerCol));
556 const cap = Math.max(6, maxRows ?? 60);
557 if (rows > cap) {
558 rows = cap;
559 columns = Math.max(20, Math.floor(rows / rowsPerCol));
560 }
561 return { file: png.file, columns, rows };
562}
563
564// Center the image horizontally in the full row width, with vertical padding;
565// the button under it (if any) centers with it.
566function centered(box, child, key, below) {
567 const { Box, Image } = box;
568 const image = Image({ key, source: child.source, columns: child.columns, rows: child.rows, alt: child.alt });
569 const children = below ? [image, Box({ width: "100%", justifyContent: "center", children: [below] })] : [image];
570 return Box({ width: "100%", flexDirection: "column", alignItems: "center", paddingY: 1, children });
571}
572
573// An Image takes no press, so a plain button under it opens the 2x PNG in the
574// system viewer (macOS `open`: zoom, fullscreen). argv, no shell: the path is
575// our own hash-named file.
576function openButton($, Button, file, key) {
577 return Button({
578 key,
579 label: "⤢ open full size",
580 plain: true,
581 dimColor: true,
582 onPress: async () => {
583 let ok = false;
584 try {
585 const r = await $.process.run(["open", file], { cwd: SAFE_CWD });
586 ok = ((r?.value ?? r)?.exitCode ?? 1) === 0;
587 } catch {
588 ok = false;
589 }
590 if (!ok) $.ui.toast(`mermaid-pane: could not open ${file}`);
591 },
592 });
593}
594
595// --- transcript scan (for the image-mode pre-warm on turn completion) -------
596
597// The newest distinct mermaid codes in the conversation, oldest first. Called
598// once a turn, so no caching; defensive about message row shapes.
599async function lastChartCodes($) {
600 let rows = [];
601 try {
602 rows = (await $.session.messages()) ?? [];
603 } catch {
604 return [];
605 }
606 const codes = [];
607 for (const t of Array.isArray(rows) ? rows : []) {
608 const text = typeof t?.text === "string" ? t.text : "";
609 const role = t?.role;
610 if (!text || (role && !/assistant|model/i.test(String(role)))) continue;
611 for (const m of text.matchAll(/```mermaid[^\n]*\n([\s\S]*?)```/g)) {
612 const code = m[1].trim();
613 if (code && !codes.includes(code)) codes.push(code);
614 }
615 }
616 return codes.slice(-3);
617}
618
619// Transcript budget: replies draw full width less the bullet indent, the
620// code-block frame's padding, and a margin so charts don't touch the edges.
621function budgetOf(e) {
622 const columns = e?.viewport?.columns ?? 120;
623 return Math.max(24, Math.min(160, columns - 14));
624}
625
626// Native cell width of a diagram, from the SVG's own viewBox (like a font's
627// point size: fixed, independent of the pane). Fallback when unreadable.
628const FALLBACK_NATIVE_COLS = 60;
629const svgCache = new Map(); // base -> native columns
630
631async function naturalCols($, code) {
632 const base = `d${hash(normalize(code))}`;
633 if (svgCache.has(base)) return svgCache.get(base);
634 let cols = FALLBACK_NATIVE_COLS;
635 // SVG sizing hits mermaid.ink — same opt-in gate as the image fallback.
636 // Re-check immediately before sending so revoke stops queued sizing work.
637 if (await getExternalAllowed($)) {
638 try {
639 if (!(await getExternalAllowed($))) {
640 svgCache.set(base, cols);
641 return cols;
642 }
643 const { exitCode, stdout } = await run($, `curl -sfL --max-time 20 '${inkUrl(code, "svg")}' | head -c 3000`);
644 if ((exitCode ?? 1) === 0) {
645 const vb = /viewBox="([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+)"/.exec(stdout ?? "");
646 const mw = /max-width:\s*(\d+)px/.exec(stdout ?? "");
647 const naturalPx = vb ? parseFloat(vb[3]) : mw ? parseFloat(mw[1]) : NaN;
648 if (Number.isFinite(naturalPx) && naturalPx > 0) cols = Math.max(24, Math.min(100, Math.round(naturalPx / 16)));
649 }
650 } catch (err) {
651 if (isAborted(err)) throw err; // a cut draw: its render is redone, not mis-sized
652 // fallback stands
653 }
654 }
655 svgCache.set(base, cols);
656 return cols;
657}
658
659// Split a reply into text parts and mermaid fence parts, in order.
660function splitByFences(text) {
661 const parts = [];
662 let last = 0;
663 for (const m of text.matchAll(/```mermaid[^\n]*\n([\s\S]*?)```/g)) {
664 if (m.index > last) parts.push({ kind: "text", text: text.slice(last, m.index) });
665 parts.push({ kind: "chart", code: m[1].trim() });
666 last = m.index + m[0].length;
667 }
668 if (last < text.length) parts.push({ kind: "text", text: text.slice(last) });
669 return parts;
670}
671
672// Replace each mermaid fence with a fenced ASCII-art block (ascii mode).
673async function embedArt($, text, budget) {
674 const matches = [...text.matchAll(/```mermaid[^\n]*\n([\s\S]*?)```/g)];
675 if (!matches.length) return null;
676 let out = "";
677 let last = 0;
678 for (const m of matches) {
679 out += text.slice(last, m.index);
680 const art = await asciiFor($, m[1].trim(), budget);
681 out += "```\n" + art + "\n```"; // fenced: the transcript preserves it verbatim
682 last = m.index + m[0].length;
683 }
684 return out + text.slice(last);
685}
686
687// ascii mode: embed each fence as rendered art (text rewrite).
688// image mode: embed a real Image element in the reply row; if the PNG can't
689// be produced, degrade to the ascii art embed. Either way the chart sits
690// with the response it belongs to.
691async function renderAssistant($, e, next) {
692 const text = e?.props?.text;
693 if (typeof text !== "string" || !text.includes("```mermaid")) return next(e);
694 const budget = budgetOf(e);
695 const maxRows = Math.max(8, Math.round((e?.viewport?.rows ?? 40) * 0.7));
696 const resolved = $.ui.resolve(e);
697 const { Box, Text, Markdown, Button } = resolved;
698 if ((await getMode($)) === "image") {
699 const blocks = [];
700 let chartIndex = 0;
701 for (const part of splitByFences(text)) {
702 if (part.kind === "text") {
703 const t = part.text.replace(/^\n+|\n+$/g, "");
704 if (t) blocks.push(Markdown({ text: t })); // prose keeps the reply's markdown: tables, code spans
705 } else {
706 const png = await ensurePng($, part.code, next);
707 if (png) {
708 const sized = sizePng(png, budget, maxRows);
709 const open = openButton($, Button, sized.file, `open-${chartIndex++}`);
710 blocks.push(centered(resolved, { source: { file: sized.file, format: "png" }, columns: sized.columns, rows: sized.rows, alt: await asciiFor($, part.code, budget) }, undefined, open));
711 } else {
712 const art = await asciiFor($, part.code, budget);
713 const diag = pngDiag(part.code);
714 const note = diag ? `\n\n(${diag})` : "";
715 blocks.push(Text({ wrap: "wrap", children: art + note }));
716 }
717 }
718 }
719 if (!blocks.length) return next(e);
720 return Box({ flexDirection: "column", children: blocks });
721 }
722 const out = await embedArt($, text, budget);
723 if (!out) return next(e);
724 return next({ ...e, props: { ...e.props, text: out } });
725}
726
727// --- optional renderers: availability + one-shot setup -----------------------
728
729// Fresh (non-memoized) probe — setup must see installs made this session.
730const probeFresh = ($, bin, prelude = "") => probeSh($, whichSh(bin, prelude));
731
732// termaid is a pure-Python package on PyPI (zero native deps). Install with
733// pip --user, then symlink the console script into ~/.local/bin so the probe
734// path (PATH, then ~/.local/bin) finds it even when the user site scripts dir
735// is not on PATH (common on macOS Homebrew/Python.org installs).
736const ASCII_INSTALL_SH =
737 `set -e; ` +
738 `command -v python3 >/dev/null 2>&1 || { echo "python3 not found — install Python 3.9+ first" >&2; exit 1; }; ` +
739 `python3 -m pip install --user --upgrade 'termaid>=0.9.0' || python3 -m pip install --user --upgrade termaid; ` +
740 `mkdir -p "$HOME/.local/bin"; ` +
741 `T=""; ` +
742 `if command -v termaid >/dev/null 2>&1; then T=$(command -v termaid); ` +
743 `elif [ -x "$HOME/.local/bin/termaid" ]; then T="$HOME/.local/bin/termaid"; ` +
744 `else ` +
745 `UB=$(python3 -c 'import os,site; print(os.path.join(site.USER_BASE,"bin"))' 2>/dev/null || true); ` +
746 `SB=$(python3 -c 'import sysconfig; print(sysconfig.get_path("scripts") or "")' 2>/dev/null || true); ` +
747 `for d in "$HOME/.local/bin" "$UB" "$SB"; do [ -n "$d" ] && [ -x "$d/termaid" ] && T="$d/termaid" && break; done; ` +
748 `fi; ` +
749 `[ -n "$T" ] || { echo "termaid installed but console script not found" >&2; exit 1; }; ` +
750 `[ "$T" = "$HOME/.local/bin/termaid" ] || ln -sf "$T" "$HOME/.local/bin/termaid"; ` +
751 `echo "installed $HOME/.local/bin/termaid ← $T"`;
752
753// mermaid-cli ships prebuilt on npm; puppeteer fetches a prebuilt Chromium on
754// first render. The spec is pinned to the Node major that will run it
755// (12.x needs Node ≥ 22.13, 11.x ≥ 18.19) so an install on an older Node
756// cannot die on engines. npm resolves through the nvm prelude.
757const MMDC_INSTALL_SH =
758 `${NVM_PATH_PRELUDE}command -v npm >/dev/null 2>&1 || { echo "npm not found — install Node first" >&2; exit 1; }; ` +
759 `M=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0); ` +
760 `S=@mermaid-js/mermaid-cli@latest; ` +
761 `[ "$M" -ge 23 ] || S=@mermaid-js/mermaid-cli@11; ` +
762 `[ "$M" -ge 19 ] || S=@mermaid-js/mermaid-cli@10.9.1; ` +
763 `npm install -g "$S"`;
764
765// beautiful-mermaid is a library (no CLI, no binary), so it goes into the
766// mod's own folder, pinned, beside a tiny stdin → ASCII runner. bun installs
767// and runs it when present, else npm/node.
768const BM_RUNNER =
769 `import { renderMermaidASCII } from "beautiful-mermaid";\n` +
770 `let src = ""; for await (const c of process.stdin) src += c;\n` +
771 `process.stdout.write(renderMermaidASCII(src, { colorMode: "none" }) + "\\n");\n`;
772const BUN_SH = `B=""; if command -v bun >/dev/null 2>&1; then B=$(command -v bun); elif [ -x "$HOME/.bun/bin/bun" ]; then B="$HOME/.bun/bin/bun"; fi; `;
773const BM_INSTALL_SH =
774 `set -e; D="${BM_DIR}"; mkdir -p "$D"; cd "$D"; ` +
775 `[ -f package.json ] || printf '{"private":true}\n' > package.json; ` +
776 BUN_SH +
777 `if [ -n "$B" ]; then "$B" add --exact beautiful-mermaid@${BM_VERSION}; ` +
778 `else ${NVM_PATH_PRELUDE}npm install --save-exact beautiful-mermaid@${BM_VERSION}; fi; ` +
779 `printf '%s' ${shellQuote(BM_RUNNER)} > render.mjs; ` +
780 `echo "installed beautiful-mermaid@${BM_VERSION} in $D"`;
781// Something that can install it: bun, else npm.
782const BM_TOOLCHAIN_SH =
783 `if command -v bun >/dev/null 2>&1; then command -v bun; elif [ -x "$HOME/.bun/bin/bun" ]; then echo "$HOME/.bun/bin/bun"; ` +
784 `else ${NVM_PATH_PRELUDE}command -v npm; fi`;
785
786// Installs run detached (nohup) so the hook never blocks on npm/pip; the log
787// lands in the private dir and a re-run of /mermaid setup reports progress.
788async function startInstall($, key, sh) {
789 try {
790 const dir = await pngDir($);
791 if (!dir) return false;
792 const script = shellQuote(`${dir}/install-${key}.sh`);
793 const r = await run(
794 $,
795 `${dirGuardSh(dir)}; printf '%s' ${shellQuote(sh)} > ${script} && nohup sh ${script} >> ${shellQuote(`${dir}/setup.log`)} 2>&1 & echo started`
796 );
797 return /started/.test(r.stdout ?? "");
798 } catch {
799 return false;
800 }
801}
802
803// --- registration ------------------------------------------------------------
804export function register(on) {
805
806 on("session.start", async ($, e, next) => {
807 const r = await next(e);
808 await $.command.register({
809 name: "mermaid",
810 description: "Mermaid charts: ascii/image mode; external on|off; setup installs renderers",
811 argumentHint: "[ascii|image|setup|external on|external off]",
812 });
813 return r;
814 });
815
816 on("turn.complete", async ($, e, next) => {
817 const r = await next(e);
818 if (e?.agentId) return r; // skip subagent loops
819 try {
820 if ((await getMode($)) === "image") {
821 for (const code of await lastChartCodes($)) await ensurePng($, code, next); // pre-warm (respects external gate)
822 if (pngsDirty) {
823 // Rows that drew without a ready PNG show the fallback; a redraw now
824 // upgrades them to the image.
825 pngsDirty = false;
826 await $.ui.invalidate("ui.render");
827 }
828 }
829 } catch {
830 // drawing must never break the turn
831 }
832 return r;
833 });
834
835 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
836 return renderAssistant($, e, next);
837 });
838
839 on("command.run", async ($, e, next) => {
840 if (e?.command === "mermaid") {
841 const arg = (e?.args ?? "").trim().toLowerCase();
842 if (/^setup/.test(arg)) {
843 const lines = [];
844 const starting = [];
845 const ascii = await probeFresh($, "termaid");
846 const mmdc = await probeFresh($, "mmdc", NVM_PATH_PRELUDE);
847 const bm = await probeSh($, BM_PROBE_SH);
848 // Renderers a background install added since the last probe draw now,
849 // not after a reload: adopt the fresh answers and drop what they shaped.
850 probedTool = ascii;
851 probedMmdc = mmdc;
852 probedBm = bm;
853 artCache.clear();
854 pngFailed.clear();
855 if (bm) lines.push("✓ beautiful-mermaid (most faithful ASCII art: flowchart, sequence, state, class, ER, XY)");
856 if (ascii) lines.push("✓ termaid (multi-type ASCII art)");
857 if (mmdc) lines.push("✓ mermaid-cli (offline PNG rendering)");
858 for (const [key, have, label] of [
859 ["ascii", ascii, "termaid"],
860 ["mmdc", mmdc, "mermaid-cli (mmdc)"],
861 ["bm", bm, "beautiful-mermaid"],
862 ]) {
863 if (have) continue;
864 if (key === "ascii" && !(await probeFresh($, "python3"))) {
865 lines.push(`✗ ${label} — install it manually (needs Python 3.9+ and pip)`);
866 continue;
867 }
868 if (key === "mmdc" && !(await probeFresh($, "npm", NVM_PATH_PRELUDE))) {
869 lines.push(`✗ ${label} — install it manually (needs Node + npm)`);
870 continue;
871 }
872 if (key === "bm" && !(await probeSh($, BM_TOOLCHAIN_SH))) {
873 lines.push(`✗ ${label} — install it manually (needs Bun, or Node + npm)`);
874 continue;
875 }
876 const sh = { ascii: ASCII_INSTALL_SH, mmdc: MMDC_INSTALL_SH, bm: BM_INSTALL_SH }[key];
877 if (await startInstall($, key, sh)) {
878 starting.push(label);
879 lines.push(`… ${label} — installing in the background`);
880 } else {
881 lines.push(`✗ ${label} — could not start the install; see /mermaid setup docs (no diagram source is logged)`);
882 }
883 }
884 let text;
885 if (starting.length) {
886 const blocked = lines.filter((l) => l.startsWith("✗"));
887 text =
888 `Installing ${starting.join(", ")} in the background (log: ${pngDirMemo ?? "~/.cache/mermaid-pane"}/setup.log).` +
889 (blocked.length ? `\n${blocked.join("\n")}` : "") +
890 `\nRe-run /mermaid setup to check; once it reports them, charts use them right away.` +
891 `\nImage mode stays local unless you /mermaid external on (sends full diagram source to mermaid.ink).`;
892 } else if (lines.every((l) => l.startsWith("✓"))) {
893 text =
894 "Everything is installed — ascii art via beautiful-mermaid and termaid, images via mermaid-cli." +
895 "\nRemote mermaid.ink fallback is opt-in: /mermaid external on (sends full diagram source; persists).";
896 } else {
897 text = `Renderer status:\n${lines.join("\n")}`;
898 }
899 return { text };
900 }
901 if (/^external\s+on\b/.test(arg) || arg === "external on") {
902 await setExternalAllowed($, true);
903 return {
904 text:
905 "External rendering ON (persists across sessions). " +
906 "When local mermaid-cli is missing or fails, the full diagram source is sent to mermaid.ink " +
907 "(image + SVG sizing). /mermaid external off to revoke.",
908 };
909 }
910 if (/^external\s+off\b/.test(arg) || arg === "external off") {
911 await setExternalAllowed($, false);
912 return {
913 text:
914 "External rendering OFF (persists). Image mode uses only local mermaid-cli; " +
915 "on failure charts fall back to ASCII. No diagram source leaves this machine via mermaid-pane.",
916 };
917 }
918 if (/^external\b/.test(arg)) {
919 const allowed = await getExternalAllowed($);
920 return {
921 text:
922 `External rendering is ${allowed ? "ON" : "OFF"} (persists). ` +
923 (allowed
924 ? "Full diagram source may be sent to mermaid.ink when local render fails. /mermaid external off to revoke."
925 : "Image mode stays local. /mermaid external on allows mermaid.ink (sends full diagram source)."),
926 };
927 }
928 if (/^(image|ascii)/.test(arg)) {
929 const mode = arg.startsWith("image") ? "image" : "ascii";
930 modeMemo = mode;
931 await $.store.set("mode", mode); // durable across sessions and reloads
932 await $.state.set(MODE, mode); // redraws the rows drawing with it
933 const external = await getExternalAllowed($);
934 return {
935 text:
936 mode === "image"
937 ? external
938 ? "Image mode: charts draw as pictures in replies (local mermaid-cli, then mermaid.ink). /mermaid ascii to switch; /mermaid external off to revoke remote."
939 : "Image mode: charts draw as pictures via local mermaid-cli only. Remote mermaid.ink is OFF — /mermaid external on to allow (sends full diagram source; persists). /mermaid ascii to switch."
940 : "ASCII mode: charts draw as art inside each reply (always local). /mermaid image to switch.",
941 };
942 }
943 const mode = await getMode($);
944 const external = await getExternalAllowed($);
945 const ascii = await probeFresh($, "termaid");
946 const mmdc = await probeFresh($, "mmdc", NVM_PATH_PRELUDE);
947 const bm = await probeSh($, BM_PROBE_SH);
948 let text = `${mode} mode — external ${external ? "ON" : "OFF"} — /mermaid ${mode === "image" ? "ascii" : "image"} to switch.`;
949 if (!ascii || !mmdc || !bm) {
950 const parts = [];
951 parts.push(`beautiful-mermaid ${bm ? "✓" : "✗"}`);
952 parts.push(`termaid ${ascii ? "✓" : "✗"}`);
953 parts.push(`mermaid-cli ${mmdc ? "✓" : "✗"}`);
954 text += `\nrenderers: ${parts.join(" · ")} — /mermaid setup installs missing ones`;
955 }
956 if (!external) {
957 text += `\nremote: OFF — /mermaid external on allows mermaid.ink (sends full diagram source; persists)`;
958 }
959 if (lastPngDiag) text += `\nlast image: ${lastPngDiag}`;
960 return { text };
961 }
962 return next(e);
963 });
964}
965types/index.d.ts 9 lines1declare module "claude-code" {
2 interface PluginState {
3 "mermaid-pane": {
4 /** How charts render this session: ascii (art in replies) or image (PNGs). */
5 mode: "ascii" | "image";
6 };
7 }
8}
9