SLOPSHOPPER

mermaid-pane

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…

newrowscommandtoastprocess
v0.4.1MITupdated 2026-10-07ezoushen/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
› fix the failing auth test and add an audit log call ⏺ 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 ⎿ mermaid-pane: ascii mode — external OFF — /mermaid image to switch. ⎿ mermaid-pane: renderers: beautiful-mermaid ✗ · termaid ✗ · mermaid-cli ✗ — /mermaid setup installs missing ones ⎿ mermaid-pane: remote: OFF — /mermaid external on allows mermaid.ink (sends full diagram source; persists) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mermaid-pane

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.

Commands

CommandWhat it does
/mermaid asciiRewrite each mermaid fence in place as rendered ASCII art
/mermaid imageKeep the source; draw each chart as a real terminal PNG (local mermaid-cli first)
/mermaid external onOpt in to mermaid.ink when local PNG fails (sends full diagram source; persists)
/mermaid external offRevoke remote rendering (default; persists). Image mode stays local-only
/mermaidReport the current mode, external setting, and renderer availability
/mermaid setupInstall 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.

Privacy: external 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.

Renderers (all optional)

RendererWhat it improvesInstall via /mermaid setup
beautiful-mermaidMost 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 termaidbun 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
termaidBetter ASCII art across many Mermaid diagram types (flowchart, sequence, class, ER, state, gantt, mindmap, …) vs the built-in edge listpip 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.

Modes

  • ascii — beautiful-mermaid for the types it draws → termaid (--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.
  • image — 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.

Development

claude plugin validate ./mermaid-pane
claude plugin test ./mermaid-pane
claude --plugin-dir ./mermaid-pane
Source 2 files
hooks/mermaid-pane.mjs 965 lines
1// 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}
965
types/index.d.ts 9 lines
1declare 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