Draws mermaid code blocks in assistant replies as text art in the terminal transcript

hooks/register.tsx 86 lines1import { update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Art } from '../types'
5import { displayWidth, fenceFor, fnv1a32, hasForbiddenControl, isMermaid, scanFences } from './text'
6
7// Columns the terminal indents an assistant reply by (spec concern 4).
8const REPLY_INDENT = 2
9const SETUP = 'npm ci --prefix ~/workspace/dotfiles/claude/.claude/mods/mermaid-ascii/render'
10const WARNED = { plugin: 'mermaid-ascii', key: 'warnedMissing' } as const
11
12export const register: Register = on => {
13 // Each id's first claimant this load, id to full source: one owner per id,
14 // so a hash collision never converts the second source (spec step 4).
15 const claims = new Map<string, string>()
16
17 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
18 if (e.surface !== 'terminal') return next(e)
19 const fences = scanFences(e.props.text).filter(f => isMermaid(f.info))
20 if (fences.length === 0) return next(e)
21
22 const room = e.viewport ? e.viewport.columns - REPLY_INDENT : Infinity
23 const lines = e.props.text.split('\n')
24 let isChanged = false
25 // Last block first, so earlier line indexes still hold after a splice.
26 for (const f of [...fences].reverse()) {
27 const id = fnv1a32(f.source)
28 const { value } = await $.state.get({ plugin: 'mermaid-ascii', key: 'art', id })
29 if (value === undefined) {
30 if (!claims.has(id)) {
31 claims.set(id, f.source)
32 $.clock.after(0, () => void convert($, id, f.source))
33 }
34 continue
35 }
36 if (value.source !== f.source || !value.ok) continue
37 const art = value.art.split('\n').map(l => f.indent + l)
38 if (art.some(l => displayWidth(l) > room)) continue
39 const ticks = f.indent + fenceFor(value.art)
40 // A CRLF reply keeps its line endings in the lines put in.
41 const cr = lines[f.end]?.endsWith('\r') ? '\r' : ''
42 const block = [`${ticks}text`, ...art, ticks].map(l => l + cr)
43 lines.splice(f.start, f.end - f.start + 1, ...block)
44 isChanged = true
45 }
46 if (!isChanged) return next(e)
47 return next({ ...e, props: { ...e.props, text: lines.join('\n') } })
48 })
49}
50
51// Runs outside drawing. Keeps a value already written, so the first result
52// for an id owns it even across a reload (spec step 5).
53async function convert($: EngineInterface, id: string, source: string): Promise<void> {
54 const result = await run($, source)
55 await update($, { plugin: 'mermaid-ascii', key: 'art', id }, prev => prev ?? result)
56}
57
58async function run($: EngineInterface, source: string): Promise<Art> {
59 const failed: Art = { source, ok: false }
60 // render.mjs runs from its real path so node finds node_modules in the
61 // repo checkout, however Stow linked ~/.claude/mods.
62 const root = `${$.plugin.root}/render`
63 const dir = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
64 let r
65 try {
66 r = await $.process.run(['node', `${dir}/render.mjs`], { cwd: dir, stdin: source, timeoutMs: 5000 })
67 } catch (err) {
68 // ENOENT: node cannot start. Anything else is taken as a timeout.
69 if (/ENOENT/.test(String((err as { code?: unknown })?.code ?? err))) await warnMissing($)
70 return failed
71 }
72 if (r.exitCode === 3) await warnMissing($)
73 if (r.exitCode !== 0) return failed
74 const art = r.stdout.split('\n').map(l => l.trimEnd()).join('\n').trimEnd()
75 // colorMode 'none' stops generated colour, not a label's own controls.
76 return art === '' || hasForbiddenControl(art) ? failed : { source, ok: true, art }
77}
78
79// One toast per session: the versioned write lets one failing block win.
80async function warnMissing($: EngineInterface): Promise<void> {
81 const { value, version } = await $.state.get(WARNED)
82 if (value) return
83 const { isSet } = await $.state.set(WARNED, true, { ifVersion: version })
84 if (isSet) $.ui.toast(`mermaid-ascii: converter not installed. Run: ${SETUP}`)
85}
86hooks/text.ts 174 lines1// Pure text helpers for the render hook: no `$`, so they test on their own.
2
3export type Fence = {
4 // Everything after the opening run, as written.
5 info: string
6 // The opening fence's indent, removed from each source line.
7 indent: string
8 source: string
9 // Line indexes of the opening and closing fence lines.
10 start: number
11 end: number
12}
13
14const OPEN = /^( {0,3})(`{3,}|~{3,})(.*)$/
15const CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
16
17// Top-level fenced code blocks that are closed (R1). A fence inside another
18// fence is part of that fence's source, so it is never returned on its own.
19export function scanFences(text: string): Fence[] {
20 // A CRLF reply's lines keep a trailing CR; it is dropped before matching,
21 // so line indexes still count the reply's own lines.
22 const lines = text.split('\n').map(l => (l.endsWith('\r') ? l.slice(0, -1) : l))
23 const found: Fence[] = []
24 let open: { run: string; indent: string; info: string; start: number } | undefined
25
26 lines.forEach((line, i) => {
27 if (!open) {
28 const m = OPEN.exec(line)
29 if (!m) return
30 const [, indent = '', run = '', info = ''] = m
31 // A backtick fence's info string may not hold a backtick.
32 if (!(run.startsWith('`') && info.includes('`'))) open = { indent, run, info, start: i }
33 return
34 }
35 const run = CLOSE.exec(line)?.[1]
36 if (run && run[0] === open.run[0] && run.length >= open.run.length) {
37 const body = lines.slice(open.start + 1, i).map(l => dedent(l, open!.indent.length))
38 found.push({ info: open.info, indent: open.indent, source: body.join('\n'), start: open.start, end: i })
39 open = undefined
40 }
41 })
42 return found
43}
44
45function dedent(line: string, n: number): string {
46 let k = 0
47 while (k < n && line[k] === ' ') k++
48 return line.slice(k)
49}
50
51export function isMermaid(info: string): boolean {
52 return info.trim().toLowerCase() === 'mermaid'
53}
54
55// Code point ranges a terminal draws two columns wide: East Asian Wide and
56// Fullwidth, and emoji drawn as pictures.
57const WIDE: [number, number][] = [
58 [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec],
59 [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615],
60 [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1],
61 [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce],
62 [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],
63 [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b],
64 [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755],
65 [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf],
66 [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0x303e],
67 [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf],
68 [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19],
69 [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x16fe0, 0x16fe4],
70 [0x17000, 0x18cff], [0x1b000, 0x1b2ff], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf],
71 [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a], [0x1f200, 0x1f251], [0x1f300, 0x1f64f],
72 [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb], [0x1f90c, 0x1f9ff], [0x1fa70, 0x1faff],
73 [0x20000, 0x2fffd], [0x30000, 0x3fffd],
74]
75
76// Zero columns: nonspacing and enclosing marks, format characters (ZWJ,
77// zero-width spaces, tags), Hangul medial and final jamo, and the skin-tone
78// modifiers that colour the emoji before them. U+00AD, a format character
79// terminals draw, is left out.
80const ZERO = /[\p{Mn}\p{Me}\p{Cf}ᅠ-ᇿ\u{1f3fb}-\u{1f3ff}]/u
81const PICTOGRAPH = /\p{Extended_Pictographic}/u
82const EMOJI = /\p{Emoji}/u
83const PRESENTED = /\p{Emoji_Presentation}/u
84const ZWJ = 0x200d
85const VS16 = 0xfe0f
86const SOFT_HYPHEN = 0xad
87
88const isRegional = (cp: number) => cp >= 0x1f1e6 && cp <= 0x1f1ff
89
90function inRanges(cp: number, ranges: [number, number][]): boolean {
91 let lo = 0
92 let hi = ranges.length - 1
93 while (lo <= hi) {
94 const mid = (lo + hi) >> 1
95 const [from, to] = ranges[mid]!
96 if (cp < from) hi = mid - 1
97 else if (cp > to) lo = mid + 1
98 else return true
99 }
100 return false
101}
102
103// Columns a line takes in the terminal (R2), glyph by glyph: marks and format
104// characters add nothing, VS16 draws a text-default emoji two wide, a ZWJ
105// joins the next pictograph into the one before, and two regional
106// indicators make one flag.
107export function displayWidth(line: string): number {
108 let col = 0
109 // The glyph before, for a VS16 or a ZWJ that follows it.
110 let last = { width: 0, isEmoji: false, isPictograph: false }
111 let isJoining = false
112 let isFlagOpen = false
113 for (const ch of line) {
114 const cp = ch.codePointAt(0)!
115 if (cp === 0x09) {
116 col += 8 - (col % 8)
117 last = { width: 0, isEmoji: false, isPictograph: false }
118 isJoining = isFlagOpen = false
119 continue
120 }
121 if (cp === VS16) {
122 if (last.isEmoji && last.width === 1) { col += 1; last.width = 2 }
123 continue
124 }
125 if (cp === ZWJ) { isJoining = last.isPictograph; continue }
126 const isPictograph = PICTOGRAPH.test(ch)
127 const isJoined = isJoining && isPictograph
128 isJoining = false
129 if (isJoined) continue
130 if (cp !== SOFT_HYPHEN && ZERO.test(ch)) continue
131 if (isRegional(cp)) {
132 // The second of a pair is part of the flag the first drew.
133 isFlagOpen = !isFlagOpen
134 if (!isFlagOpen) continue
135 col += 2
136 last = { width: 2, isEmoji: true, isPictograph: false }
137 continue
138 }
139 isFlagOpen = false
140 const width = inRanges(cp, WIDE) || PRESENTED.test(ch) ? 2 : 1
141 col += width
142 last = { width, isEmoji: EMOJI.test(ch), isPictograph }
143 }
144 return col
145}
146
147// Message text may hold no control character but tab and newline (R7): the
148// engine skips a hook whose rewrite holds one. C0, DEL and C1.
149export function hasForbiddenControl(text: string): boolean {
150 return /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(text)
151}
152
153// A backtick fence the art cannot close early: at least three, and longer
154// than any backtick run inside it.
155export function fenceFor(art: string): string {
156 let longest = 0
157 for (const run of art.match(/`+/g) ?? []) longest = Math.max(longest, run.length)
158 return '`'.repeat(Math.max(3, longest + 1))
159}
160
161// FNV-1a, 32 bits, over UTF-8 bytes, as 8 hex digits: names a state member.
162export function fnv1a32(text: string): string {
163 let h = 0x811c9dc5
164 const eat = (b: number) => { h = Math.imul(h ^ b, 0x01000193) >>> 0 }
165 for (const ch of text) {
166 const cp = ch.codePointAt(0)!
167 if (cp < 0x80) eat(cp)
168 else if (cp < 0x800) { eat(0xc0 | (cp >> 6)); eat(0x80 | (cp & 63)) }
169 else if (cp < 0x10000) { eat(0xe0 | (cp >> 12)); eat(0x80 | ((cp >> 6) & 63)); eat(0x80 | (cp & 63)) }
170 else { eat(0xf0 | (cp >> 18)); eat(0x80 | ((cp >> 12) & 63)); eat(0x80 | ((cp >> 6) & 63)); eat(0x80 | (cp & 63)) }
171 }
172 return h.toString(16).padStart(8, '0')
173}
174types/index.d.ts 16 lines1// A block's conversion result, keyed by fnv1a32(source). `source` is kept so
2// a hash collision is never mistaken for a hit.
3export type Art =
4 | { source: string; ok: true; art: string }
5 | { source: string; ok: false }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'mermaid-ascii': {
10 art: StateFamily<Art>
11 // The missing-converter toast has been shown this session.
12 warnedMissing: boolean
13 }
14 }
15}
16