SLOPSHOPPER

mermaid-ascii

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

newrowstoastprocesstimer
A shopper browsing a rack in a slop shop
Source 3 files
hooks/register.tsx 86 lines
1import { 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}
86
hooks/text.ts 174 lines
1// 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}
174
types/index.d.ts 16 lines
1// 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