Claude Code mods: draws mermaid diagrams in replies as Unicode text in the terminal

Claude Code mods from SimpleClaude. A mod is a plugin that changes Claude Code's own interface; see the mods documentation.
sc-mods holds one mod today: it draws the mermaid diagrams in Claude's replies as Unicode text, so you can read a diagram in the terminal without copying it into a renderer.
When a reply contains a closed `mermaid code block, the mod runs merman on the diagram and shows the drawing in place of the source. Nothing else in the reply changes, and the conversation the model sees still holds the mermaid source.
Before:
flowchart LR Plan --> Build --> Review --> Ship
After:
┌──────┐ ┌───────┐ ┌────────┐ ┌──────┐
│ │ │ │ │ │ │ │
│ Plan ├──►│ Build ├──►│ Review ├──►│ Ship │
│ │ │ │ │ │ │ │
└──────┘ └───────┘ └────────┘ └──────┘
The drawing has to fit the terminal's width. For a flowchart that is too wide, the mod tries a compact layout, then wraps long node labels, then turns a left-to-right chart top-to-bottom. A sequence diagram gets a second, automatic layout. If no layout fits, or merman cannot read the diagram, the source stays as it was.
claude plugin install sc-mods --marketplace kylesnowschwartz/SimpleClaude
This installs from the sc-mods-dist branch, which carries merman-cli for macOS and Linux on arm64 and x86_64. Nothing is downloaded when the mod runs.
A mod runs with your permissions. It runs inside Claude Code and can start programs as you. This one starts only merman-cli, it makes no network calls, and it writes no files. Read hooks/register.ts before you install it.
| Setting | What it does |
|---|---|
MERMAN_PATH | Absolute path to a merman-cli to run instead of the bundled one. Leave it unset to use the bundled one. Set it at install with --config MERMAN_PATH=/path/to/merman-cli, or later with /plugin configure. |
If merman-cli cannot run, every diagram stays as source and Claude Code shows one notice saying how to fix it.
The binaries are not in the main branch. Fetch them into bin/ first:
just fetch-merman
This runs scripts/fetch-merman.sh, which downloads the pinned merman release, checks each archive against its published checksum, and puts one binary per platform beside the bin/merman-cli launcher, with merman's licenses in bin/merman-licenses/. Running it again does nothing. just check-merman reports whether a newer merman release is out.
Then load the plugin from the checkout, either for one session:
claude --plugin-dir plugins/sc-mods
or as an installed plugin that reads straight from your checkout:
claude plugin marketplace add /path/to/SimpleClaude
claude plugin install sc-mods-dev@simpleclaude
With sc-mods-dev, edit the files and run /reload-plugins. Don't install sc-mods and sc-mods-dev together, or every diagram is handled twice.
Check and test the mod:
just test-mods
Every just release republishes the sc-mods-dist branch. To publish it on its own, run just publish-mods, which stops unless all four binaries are present.
MERMAN_PATH points at a merman-cli.hooks/register.ts 96 lines1import type { EngineInterface, Register } from 'claude-code'
2import { drawDiagram, type Runner } from './merman'
3
4// Only closed fences match, so a fence still streaming in is left as source.
5const CLOSED_FENCE = /^```mermaid[ \t]*\n([\s\S]*?)\n```[ \t]*$/gm
6
7// The reply's indent plus a margin column on each side.
8const GUTTER = 4
9const WIDTH_WITHOUT_VIEWPORT = 100
10const RUN_TIMEOUT_MS = 5000
11// A new binary's first launch can wait on the OS scanning it.
12const CHECK_TIMEOUT_MS = 15000
13const NOTICE_TIMEOUT_MS = 15000
14
15// A dev checkout loads the plugin from the repository's plugins/ folder;
16// an install loads it from Claude Code's plugin cache.
17const DEV_CHECKOUT_ROOT = /\/plugins\/sc-mods\/?$/
18
19// How Claude Code words a $.process.run rejection for a command that
20// outlived its timeoutMs; any other rejection is a failure to run.
21const TIMED_OUT = /still running after/
22
23/**
24 * Each diagram's drawing, keyed by width and source; undefined keeps the
25 * source. Only settled outcomes stay: a draw that rejected is dropped so a
26 * later render tries it again.
27 */
28const drawings = new Map<string, Promise<string | undefined>>()
29
30/** Whether merman-cli runs; `timed-out` is not an answer and is asked again. */
31type MermanCheck = 'runnable' | 'missing' | 'timed-out'
32
33let mermanCheck: Promise<MermanCheck> | undefined
34
35const textBlock = (drawing: string) => '```text\n' + drawing + '\n```'
36
37function missingMermanNotice(pluginRoot: string): string {
38 const remedy = DEV_CHECKOUT_ROOT.test(pluginRoot)
39 ? 'run scripts/fetch-merman.sh in the SimpleClaude checkout'
40 : 'reinstall the sc-mods plugin, or set MERMAN_PATH to a merman-cli'
41 return `sc-mods: merman-cli could not run, so mermaid diagrams stay as source. To fix it, ${remedy}.`
42}
43
44async function checkMerman($: EngineInterface, bin: string): Promise<MermanCheck> {
45 try {
46 const { exitCode } = await $.process.run([bin, '--version'], { timeoutMs: CHECK_TIMEOUT_MS })
47 if (exitCode === 0) return 'runnable'
48 } catch (error) {
49 if (error instanceof Error && TIMED_OUT.test(error.message)) return 'timed-out'
50 }
51 $.ui.toast(missingMermanNotice($.plugin.root), { timeoutMs: NOTICE_TIMEOUT_MS })
52 return 'missing'
53}
54
55function drawingFor(run: Runner, source: string, width: number): Promise<string | undefined> {
56 const key = `${width}\0${source}`
57 let drawing = drawings.get(key)
58 if (drawing === undefined) {
59 const attempt = drawDiagram(run, source, width)
60 attempt.catch(() => {
61 if (drawings.get(key) === attempt) drawings.delete(key)
62 })
63 drawings.set(key, attempt)
64 drawing = attempt
65 }
66 return drawing.catch(() => undefined)
67}
68
69export const register: Register = (on, options) => {
70 const configuredPath = typeof options.MERMAN_PATH === 'string' ? options.MERMAN_PATH.trim() : ''
71
72 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
73 const text = e.props.text
74 const sources = [...text.matchAll(CLOSED_FENCE)].map(match => match[1] ?? '')
75 if (sources.length === 0) return next(e)
76
77 const bin = configuredPath || `${$.plugin.root}/bin/merman-cli`
78 const pendingCheck = (mermanCheck ??= checkMerman($, bin))
79 const check = await pendingCheck
80 if (check === 'timed-out' && mermanCheck === pendingCheck) mermanCheck = undefined
81 if (check !== 'runnable') return next(e)
82
83 const width = e.viewport ? e.viewport.columns - GUTTER : WIDTH_WITHOUT_VIEWPORT
84 const run: Runner = (args, stdin) => $.process.run([bin, ...args], { stdin, timeoutMs: RUN_TIMEOUT_MS })
85 const pending = sources.map(source => drawingFor(run, source, width))
86 const drawn = new Map<string, string | undefined>()
87 for (const [index, source] of sources.entries()) drawn.set(source, await pending[index])
88
89 const rewritten = text.replace(CLOSED_FENCE, (fence, source: string) => {
90 const drawing = drawn.get(source)
91 return drawing === undefined ? fence : textBlock(drawing)
92 })
93 return next({ ...e, props: { ...e.props, text: rewritten } })
94 })
95}
96hooks/merman.ts 79 lines1export type RunResult = { exitCode: number; stdout: string; stderr: string }
2
3/** Runs merman-cli with these arguments and the diagram source on stdin. */
4export type Runner = (args: string[], stdin: string) => Promise<RunResult>
5
6const RENDER = ['render', '-q', '-f', 'unicode', '--ascii-trim-trailing-spaces']
7const COMPACT = ['--ascii-layout-profile', 'compact']
8const AUTO = ['--ascii-layout-profile', 'auto']
9const MIRROR_ACTORS = ['--sequence-mirror-actors']
10const OVERFLOW = /exceeds requested width/
11// Anchored at the start of a diagram's body, where its keyword line stands.
12const HORIZONTAL_HEADER = /^([ \t]*(?:flowchart|graph)\s+)(LR|RL)\b/
13// What mermaid allows before the keyword line: one `---` frontmatter block,
14// then blank lines, `%%` comment lines and `%%{...}%%` directives.
15const PREAMBLE = /^(?:\s*---[ \t]*\n[\s\S]*?\n[ \t]*---[ \t]*(?:\n|$))?(?:[ \t]*(?:%%\{[\s\S]*?\}%%[ \t]*|%%.*)?(?:\n|$))*/
16
17type Attempt = { source: string; flags: string[] }
18
19/** Splits a diagram into what precedes its keyword line and the rest. */
20function splitPreamble(source: string): { preamble: string; body: string } {
21 const preamble = PREAMBLE.exec(source)?.[0] ?? ''
22 return { preamble, body: source.slice(preamble.length) }
23}
24
25const diagramType = (source: string) => splitPreamble(source).body.trimStart().split(/\s/)[0] ?? ''
26
27/** The same diagram drawn top to bottom, when its header is LR or RL. */
28function verticalVariant(source: string): string | undefined {
29 const { preamble, body } = splitPreamble(source)
30 return HORIZONTAL_HEADER.test(body) ? preamble + body.replace(HORIZONTAL_HEADER, '$1TD') : undefined
31}
32
33const wrapLabels = (columns: number) => [...COMPACT, '--ascii-flowchart-node-label-wrap-width', String(columns)]
34
35function flowchartAttempts(source: string): Attempt[] {
36 // The auto profile draws the canonical layout and switches to compact only
37 // when canonical is too wide, because compact merges the borders of
38 // side-by-side subgraphs. The label-wrap steps run only once a diagram is
39 // already too wide, so they stay compact.
40 const layouts = [AUTO, wrapLabels(12), wrapLabels(6)]
41 const vertical = verticalVariant(source)
42 const sources = vertical === undefined ? [source] : [source, vertical]
43 return sources.flatMap(s => layouts.map(flags => ({ source: s, flags })))
44}
45
46/** The layouts to try for one diagram, in the order tried. */
47function attemptsFor(source: string): Attempt[] {
48 switch (diagramType(source)) {
49 case 'flowchart':
50 case 'graph':
51 return flowchartAttempts(source)
52 case 'sequenceDiagram':
53 return [
54 { source, flags: MIRROR_ACTORS },
55 { source, flags: [...MIRROR_ACTORS, '--ascii-layout-profile', 'auto'] },
56 ]
57 default:
58 // merman rejects the compact and auto layout profiles for state diagrams.
59 return [{ source, flags: [] }]
60 }
61}
62
63const trimEnd = (text: string) => text.replace(/[ \t]+$/gm, '').replace(/\n+$/, '')
64
65/**
66 * Draws one mermaid diagram as Unicode text no wider than `width` columns,
67 * trying each layout in turn until one fits. Undefined when none fits or
68 * merman refuses the source.
69 */
70export async function drawDiagram(run: Runner, source: string, width: number): Promise<string | undefined> {
71 const bound = ['--ascii-max-width', String(width), '--ascii-overflow', 'error', '-o', '-', '-']
72 for (const { source: attemptSource, flags } of attemptsFor(source)) {
73 const result = await run([...RENDER, ...flags, ...bound], attemptSource)
74 if (result.exitCode === 0) return trimEnd(result.stdout)
75 if (!OVERFLOW.test(result.stderr)) return undefined
76 }
77 return undefined
78}
79