Draws ```mermaid blocks in Claude's replies as Unicode box-drawing diagrams in the terminal


A Claude Code mod that draws ``` `mermaid ``` blocks in Claude's replies as Unicode box-drawing diagrams in the terminal.
Claude writes:
flowchart TD
A[mermaid block in a reply] --> B[boxmaid]
B -->|drawn| D[diagram in the terminal]
B -->|refused| C[merman-cli]
C -->|drawn| D
B -->|too wide| E[source kept]
C -->|failed| E
You see:
┌──────────────────────────┐
│ mermaid block in a reply │
└─────────────┬────────────┘
│
▼
┌─────────┐
│ boxmaid │
└────┬────┘
│
┌──────────────┴─┬─────────────┐
│ │ │
drawn refused too wide
│ ▼ │
│ ┌────────────┐ │
│ │ merman-cli │ │
│ └──────┬─────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ drawn failed │
│ │ │ │
│ │ │ ┌┘
▼ ▼ ▼ ▼
┌─────────────────────────┐ ┌─────────────┐
│ diagram in the terminal │ │ source kept │
└─────────────────────────┘ └─────────────┘
macOS or Linux, in a shell:
curl -fsSL https://raw.githubusercontent.com/BukeLy/claude-mermaid-ascii/main/install.sh | sh
Or paste this to Claude Code:
Install the mermaid-ascii mod: read https://raw.githubusercontent.com/BukeLy/claude-mermaid-ascii/main/install.sh, then run `curl -fsSL https://raw.githubusercontent.com/BukeLy/claude-mermaid-ascii/main/install.sh | sh` and tell me what it printed.
If boxmaid is already on your PATH, install only the mod, at the Claude Code prompt:
/plugin install mermaid-ascii --marketplace BukeLy/claude-mermaid-ascii
Then start a new Claude Code session (or run /reload-plugins in an open one) and ask Claude for a Mermaid diagram. Needs Claude Code 2.1.287 or later, where mods are on by default.
The script installs boxmaid and merman-cli (pinned versions, SHA-256 checked) to ~/.local/bin, then installs the mod.
The mod sends each Mermaid block, as standard input, to the local boxmaid and, if boxmaid refuses it, merman-cli, and shows their output in place of the block in the terminal. It makes no network requests. The stored message keeps the Mermaid source; a block neither tool can draw within the terminal width stays as source.
hooks/register.ts 88 lines1import type { Register } from 'claude-code'
2
3const FENCE = /```mermaid[^\n]*\n([\s\S]*?)\n```/g
4// ponytail: approximate East Asian Wide ranges; emoji and rarer wide blocks still count as one column
5const WIDE =
6 /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]/g
7// The transcript indents a reply by two columns; keep a little slack for the fence.
8const MARGIN = 4
9// boxmaid's exit code when the diagram cannot be laid out within -w columns.
10const CANNOT_FIT = 3
11// boxmaid draws every kind and fits the width itself; merman-cli covers what it
12// refuses, such as an erDiagram with CJK entity names.
13const boxmaid = (columns: number) =>
14 ['boxmaid', '-color', 'never', '-w', String(columns), '-require-fit', '-']
15const MERMAN = ['merman-cli', 'render', '-q', '-f', 'unicode', '-o', '-', '-']
16
17const fence = (drawn: string) => '```\n' + drawn + '\n```'
18
19const width = (drawn: string) =>
20 Math.max(
21 ...drawn.split('\n').map(line => line.trimEnd().length + (line.match(WIDE)?.length ?? 0)),
22 )
23
24const firstLine = (text: string) => text.trim().split('\n')[0]
25
26export const register: Register = on => {
27 // ponytail: unbounded per-session cache; a session draws a handful of diagrams
28 const drawings = new Map<string, Promise<string | null>>()
29
30 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
31 if (e.surface !== 'terminal' || !e.props.text.includes('```mermaid')) {
32 return next(e)
33 }
34
35 // 0 tells boxmaid the width is unlimited.
36 const columns = e.viewport ? Math.max(1, e.viewport.columns - MARGIN) : 0
37 const run = (argv: string[], source: string) =>
38 $.process.run(argv, { stdin: source, timeoutMs: 5000 })
39
40 const render = async (source: string) => {
41 const box = await run(boxmaid(columns), source)
42
43 if (box.exitCode === 0) {
44 return box.stdout.trimEnd()
45 }
46
47 if (box.exitCode === CANNOT_FIT) {
48 return null
49 }
50
51 const merman = await run(MERMAN, source)
52
53 if (merman.exitCode !== 0) {
54 throw new Error(`boxmaid: ${firstLine(box.stderr)}; merman-cli: ${firstLine(merman.stderr)}`)
55 }
56
57 const drawn = merman.stdout.trimEnd()
58
59 return columns === 0 || width(drawn) <= columns ? drawn : null
60 }
61
62 const draw = async (source: string) => {
63 const key = `${columns}\n${source}`
64
65 if (!drawings.has(key)) {
66 drawings.set(key, render(source))
67 }
68
69 const drawn = await drawings.get(key)!.catch(error => {
70 drawings.delete(key)
71 $.ui.status(`mermaid-ascii: ${error}`)
72
73 return null
74 })
75
76 return drawn === null ? null : fence(drawn)
77 }
78
79 const drawn = await Promise.all(
80 [...e.props.text.matchAll(FENCE)].map(([, source]) => draw(source ?? '')),
81 )
82 let i = 0
83 const text = e.props.text.replace(FENCE, block => drawn[i++] ?? block)
84
85 return next({ ...e, props: { ...e.props, text } })
86 })
87}
88