Mermaid diagrams drawn inside Claude Code replies, and a truecolor status line under the prompt with a Token Weather forecast of the context window.

Mermaid diagrams drawn inside Claude Code replies, instead of raw source, and a truecolor status line under the prompt with a Token Weather forecast of the context window.
<img alt="A Claude Code reply with a Mermaid flowchart: first drawn as box-drawing text, then replaced by a picture of the same diagram." src="docs/images/demo-light.gif" width="960">
The diagram in the animation is the plugin's own output: its text drawing, then its picture in Ghostty or kitty. The window around it is recreated. The gallery shows one diagram of every supported type.
/rich open opens a diagram as a zoomable page that works offline./rich status (see Status line).Terminal drawings cover flowcharts, sequence, state, class, ER and xy charts. Other types (pie, gantt, mindmap, timeline, gitGraph, journey) stay as source, unless pictures are on.
<!-- gallery:start -->
One diagram of every supported type, in a reply in a terminal 120 columns wide. Each diagram is the plugin's own output; only the window around it is recreated. The first six types are drawn as text in any terminal and as pictures in Ghostty and kitty; the rest are pictures only. Charts marked "example data" or "illustrative" show made-up numbers.
Text drawing. Too wide left to right for the terminal, so the plugin redrew it top to bottom.
<img alt="Flowchart drawn as box-drawing text in a terminal reply" src="docs/images/gallery/flowchart-text-light.png">
Picture.
<img alt="Flowchart drawn as a picture in a terminal reply" src="docs/images/gallery/flowchart-picture-light.png">
Source:
flowchart LR
subgraph reply [Assistant reply]
M[Markdown text]
end
M --> F{Mermaid fence?}
F -->|no| N[Draw as usual]
F -->|yes| W{Fits the width?}
W -->|LR fits| T[Text drawing]
W -->|only TD fits| R[Redraw top to bottom] --> T
W -->|neither| S[Keep the source]
T --> P{Pictures on?}
P -->|Ghostty or kitty| C[(Headless Chrome)]
C --> I([PNG in place])
P -->|other terminal| K([Text stays])
Text drawing.
<img alt="Sequence diagram drawn as box-drawing text in a terminal reply" src="docs/images/gallery/sequence-text-light.png">
Picture.
<img alt="Sequence diagram drawn as a picture in a terminal reply" src="docs/images/gallery/sequence-picture-light.png">
Source:
sequenceDiagram
actor U as User
participant CC as Claude Code
participant RT as rich-terminal
participant HC as Headless Chrome
U->>CC: Prompt
activate CC
loop each streamed chunk
CC->>RT: ui.render(text)
RT-->>CC: Text drawing or source
end
CC-->>U: Reply complete
deactivate CC
alt images on
RT->>HC: Image page
HC-->>RT: Screenshot PNG
RT-->>CC: Picture
else images off
Note over RT: Text drawing stays
end
U->>CC: /rich open 2
CC->>RT: Command
RT-->>U: Browser view
Text drawing.
<img alt="State diagram drawn as box-drawing text in a terminal reply" src="docs/images/gallery/state-text-light.png">
Picture.
<img alt="State diagram drawn as a picture in a terminal reply" src="docs/images/gallery/state-picture-light.png">
Source:
stateDiagram-v2
[*] --> Streaming
Streaming --> Placeholder: fence open
Placeholder --> Drawn: fence closed
state Drawn {
[*] --> LeftToRight
LeftToRight --> TopToBottom: too wide
TopToBottom --> Source: still too wide
}
Drawn --> Picture
Picture --> Drawn: file cleared
Picture --> [*]
Text drawing.
<img alt="Class diagram drawn as box-drawing text in a terminal reply" src="docs/images/gallery/class-text-light.png">
Picture.
<img alt="Class diagram drawn as a picture in a terminal reply" src="docs/images/gallery/class-picture-light.png">
Source:
classDiagram
class Diagram {
+id: string
+kind: string
+source: string
+start: number
+end: number
+closed: boolean
+lead: string
+prefix: string
}
class Segment {
<<union>>
+kind: string
}
class TextSegment {
+text: string
}
class DiagramSegment {
+diagram: Diagram
}
Segment <|-- TextSegment
Segment <|-- DiagramSegment
DiagramSegment --> "1" Diagram : diagram
Text drawing.
<img alt="Entity relationship diagram drawn as box-drawing text in a terminal reply" src="docs/images/gallery/er-text-light.png">
Picture.
<img alt="Entity relationship diagram drawn as a picture in a terminal reply" src="docs/images/gallery/er-picture-light.png">
Source:
erDiagram
MARKETPLACE ||--o{ PLUGIN : lists
PLUGIN ||--o{ RELEASE : publishes
PLUGIN ||--o{ INSTALL : "installed as"
PROJECT ||--o{ INSTALL : scopes
RELEASE ||--o{ INSTALL : pins
MARKETPLACE {
string name PK
string repo
}
PLUGIN {
string name PK
string description
}
RELEASE {
string version PK
string commit
}
INSTALL {
string scope
string installPath
}
Text drawing.
<img alt="XY chart drawn as box-drawing text in a terminal reply" src="docs/images/gallery/xychart-text-light.png">
Picture.
<img alt="XY chart drawn as a picture in a terminal reply" src="docs/images/gallery/xychart-picture-light.png">
Source:
xychart-beta
title "Diagrams drawn per day, example data"
x-axis [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
y-axis "Diagrams" 0 --> 60
bar [12, 28, 35, 41, 52, 18, 9]
line [10, 22, 30, 38, 47, 20, 11]
<img alt="Pie chart drawn as a picture in a terminal reply" src="docs/images/gallery/pie-picture-light.png">
Source:
pie showData title Diagram types in replies, example data
"Flowchart" : 46
"Sequence" : 21
"State" : 9
"Class" : 8
"ER" : 6
"Other" : 10
<img alt="Gantt chart drawn as a picture in a terminal reply" src="docs/images/gallery/gantt-picture-light.png">
Source:
gantt
title One reply with pictures on, illustrative timing
dateFormat x
axisFormat %S s
section Claude Code
Reply streams :stream, 0, 3000ms
section rich-terminal
Placeholder, fence open :hold, 1000, 1000ms
Text drawing :text, after hold, 2500ms
Headless Chrome draws PNG :chrome, after stream, 1500ms
Picture shown :milestone, after chrome, 0ms
<img alt="Mindmap drawn as a picture in a terminal reply" src="docs/images/gallery/mindmap-picture-light.png">
Source:
mindmap
root((rich-terminal))
Text drawings
Flowchart
Sequence
State
Class
ER
XY chart
Pictures
Ghostty
kitty
Headless Chrome
Browser view
Zoom and pan
Source toggle
Offline only
<img alt="Timeline drawn as a picture in a terminal reply" src="docs/images/gallery/timeline-picture-light.png">
Source:
timeline
title rich-terminal so far
2026-10-01 : Getting started with Claude Code mods published
2026-10-03 : v0.1.0 text drawings, pictures, browser view
: v0.1.1 cache folder fixes
2026-10-04 : Text renderer 4.0.0
: README demo and gallery
<img alt="Git graph drawn as a picture in a terminal reply" src="docs/images/gallery/gitgraph-picture-light.png">
Source:
gitGraph
commit id: "draw diagrams" tag: "v0.1.0"
commit id: "cache under data"
commit id: "retry cache" tag: "v0.1.1"
branch readme-demo
checkout readme-demo
commit id: "renderer 4.0.0"
commit id: "ER edge patch"
commit id: "README demo and gallery"
checkout main
merge readme-demo
<img alt="User journey drawn as a picture in a terminal reply" src="docs/images/gallery/journey-picture-light.png">
Source:
journey
title Reading a diagram in a reply
section Without the plugin
Scroll past raw source: 2: User
Copy it to a renderer: 1: User
section With the plugin
See the text drawing: 4: User
See the picture: 5: User
Open it in the browser: 5: User
<!-- gallery:end -->
A status line under the prompt, here in the three-row layout (/rich status lines; colors omitted):
~/myproject/src > main ↑1 +12 -3 ?2
Fable · xhigh > ☁ ctx 42% > 12m · $1.23
xiaolai > 5h 12% · 7d 67%
Run /rich status setup once. A plugin cannot draw under the prompt itself, only Claude Code's statusLine setting can, and that setting needs a stable path, while the plugin's own folder moves on every update. So setup copies three scripts from the plugin (rich-status.sh, the renderer; rich-status-ctl.sh, which /rich status runs; and rich-status-lib.sh, their shared settings code) into Claude Code's config folder (~/.claude/, or $CLAUDE_CONFIG_DIR) and points statusLine at them, keeping every other setting. After a plugin update, the next session brings the copies up to date, but only copies this plugin installed and nobody has edited since; an edited copy is left alone and /rich status check reports it.
Before 1.0.0 these files were named statusline-command.sh, statusline-ctl.sh, statusline-lib.sh and statusline.state. A session start moves such a setup to the new names, settings included, when the old copies are exactly what the plugin installed; otherwise /rich status check says so, and /rich status setup moves it, leaving an edited old copy in place.
Segments are grouped A / B / C for the multi-line layouts. Each appears only when it has data.
| Group | Segment | Content |
|---|---|---|
| A | Folder | Project-relative path (project/sub/dir) inside the session's project, otherwise the ~-abbreviated absolute path |
| A | Branch | Green when the working tree is clean, amber when anything is modified or untracked; ↑n ↓m against the upstream |
| A | Changes | +insertions −deletions against HEAD, plus ?n untracked files |
| B | Model | Model short name (Fable, Opus, …) and the live reasoning effort |
| B | Context | ctx 42%, or an 8-cell █████░░░ gauge with bar, with Token Weather around it |
| B | Time · cost | Session duration (45s, 12m, 1h5m) and total API cost |
| C | Account | The signed-in account from ~/.claude.json, cached for 180 s: the email's local part, or the whole address |
| C | Limits | 5-hour and 7-day rate-limit use; reset countdowns like (3h) or (2d5h) with reset |
The context and limit gauges share one color scale: green below 60 %, amber from 60 %, red from 85 %.
The context gauge is led by a forecast icon: ☁ ctx 42%.
| Context fill | Icon | Nerd Font glyph (icons) | Forecast |
|---|---|---|---|
| below 25 % | ☀ | U+E30D weather-day_sunny | Clear |
| 25–49 % | ☁ | U+E312 weather-cloudy | Cloudy |
| 50–74 % | ☂ | U+E319 weather-showers | Showers |
| 75–89 % | ☇ | U+E31D weather-thunderstorm | Storm |
| 90 % and up | ↯ | U+E351 weather-tornado | Compact soon |
The standard symbols are missing from most coding fonts, so the terminal borrows them from a fallback font and they can look out of place. With a Nerd Font, /rich status icons switches to its weather glyphs, one cell wide in a Mono variant.
/rich status <action> changes one setting; it shows on the next status update. The settings live in ~/.claude/rich-status.state as KEY=VALUE lines, which you can also edit by hand.
| Action | Key | Default | Effect |
|---|---|---|---|
theme | THEME, STYLE | plain | Cycle the look: plain → gray → aurora → sunset → forest; the four gradients draw powerline segments |
style | STYLE | plain | Toggle powerline ↔ plain, keeping the theme |
lines | LINES | 1 | Cycle auto → 1 → 2 → 3 rows; auto measures the terminal |
toggle / hide / show | HIDDEN | 0 | Hide the status line (it prints one blank line), or show it |
bar | SHOW_BAR | 0 | Context as a ████░░ gauge instead of ctx N% |
account | SHOW_ACCOUNT | 1 | Show or hide the account; ACCOUNT_LOCAL=0 in the file shows the whole email |
reset | SHOW_RESET | 0 | Reset countdowns after the 5h/7d figures |
weather | SHOW_WEATHER | 1 | The Token Weather icon on the context gauge |
icons | ICONS | unicode | Weather icons: standard symbols, or Nerd Font glyphs (nerd) |
Powerline gradients run across each row's segments: gray #4a4a4a → #1e1e1e, aurora #1e2a4a → #52304f, sunset #241f42 → #5e3040, forest #163a34 → #2c3a55. The foreground colors carry meaning in both styles: green clean/ahead/low, amber dirty/behind/mid, red deletions/high, blue path, purple model, gold cost.
flowchart LR
CC["Claude Code"] -->|"status JSON on stdin"| R["rich-status.sh"]
R -->|"ANSI rows"| SL["status line"]
CMD["/rich status action"] --> CTL["rich-status-ctl.sh"]
CTL -->|"rewrites"| ST["~/.claude/rich-status.state"]
ST -.->|"read on every render"| R
G["git, time-bounded"] -.->|"branch and diff"| R
Claude Code runs the statusLine command on every status update and pipes it a JSON payload, which the renderer parses with jq. /rich status runs the controller from the plugin itself; it rewrites the state file through a temp file and an atomic rename. LINES=auto reads the terminal's width from /dev/tty, since the payload carries none, and falls back to three rows where that fails.
Git is bounded per call (GIT_BUDGET, default 2 s) and in total (GIT_TOTAL, default 3 s): Claude Code blanks a status line that takes 5 s, so a slow repository loses only its git segments. Git runs nothing a repository's config names: every call passes -c core.fsmonitor=false, and the diff passes --no-ext-diff --no-textconv. Set either budget, 1 to 60 whole seconds, in the command, e.g. GIT_BUDGET=1 bash ~/.claude/rich-status.sh; any other value is ignored.
| Requirement | Needed for | Without it |
|---|---|---|
jq | parsing the status JSON: the whole line | nearly empty; macOS ships /usr/bin/jq, else brew install jq |
| bash, standard Unix tools | running the scripts | stock macOS bash 3.2 works |
| A truecolor terminal (Ghostty, iTerm2, kitty, WezTerm) | the colors | colors degrade in Terminal.app; layout still works |
| The U+E0B0 glyph (a Nerd Font; Ghostty bundles the symbols) | the powerline looks' arrows | missing-glyph boxes; plain needs none |
| Nerd Font weather glyphs | icons set to nerd | missing-glyph boxes; keep unicode |
git | the branch and changes | those segments hide |
timeout or perl | bounding one slow git call | git is bounded only between calls |
| Symptom | Cause and fix |
|---|---|
| Nothing under the prompt | /rich status check shows whether statusLine runs the copied scripts; /rich status setup wires it |
| Nearly empty line | jq is missing |
| Boxes instead of arrows | a powerline look without the U+E0B0 glyph: use a Nerd Font, or /rich status style for plain |
| Weather icon looks out of place | the font lacks the symbol: with a Nerd Font, /rich status icons |
| Toggle seems to do nothing | the line redraws on the next status update: send a message or wait |
lines auto always gives 3 rows | /dev/tty is not readable here: choose 1, 2 or 3 |
check says a copy was edited | you or another tool changed it, so updates leave it alone; /rich status setup replaces it |
To remove it: delete the statusLine key from settings.json, then the copied rich-status*.sh, rich-status.state and .rich-status-account in ~/.claude/.
| Command | Effect | |
|---|---|---|
/rich list | Number the diagrams in this conversation, with their ids | |
| `/rich open [n\ | id]` | Open diagram n (or by id) in the default browser; the latest without one. Uses open on macOS and xdg-open on Linux |
/rich on / /rich off | Draw diagrams, or leave replies as Claude Code draws them | |
/rich status | Show the status line settings | |
/rich status setup | Copy the status line scripts and point the statusLine setting at them | |
/rich status check | Say whether each copied script is up to date, older, edited or missing, and whether the statusLine setting runs the copies | |
/rich status <action> | Change one status line setting; see Status line settings |
In /config, under this plugin:
| Setting | Default | Meaning |
|---|---|---|
images | off | Draw diagrams as pictures. Needs a terminal with the kitty graphics protocol (Ghostty, kitty; not inside tmux) and Chrome, Chromium, Edge or Brave. Elsewhere the text drawing is kept |
imageTheme | dark | Mermaid theme for pictures: dark, or default for a light terminal |
browser | empty | Path to the browser that draws pictures; empty finds one |
Pictures are drawn once per diagram by headless Chrome. The text drawing shows until the picture is ready, about a second and a half. Pages are drawn offline: their content policy blocks every network request, so a diagram cannot make the browser fetch anything.
Browser pages and pictures are kept in ~/.claude/plugins/data/rich-terminal/, an owner-only folder. They contain the source of the diagrams you opened or drew as pictures, plus a headless-Chrome profile per session. Nothing is removed automatically; delete the folder at any time to clear it.
Requires Claude Code 2.1.287 or later.
claude plugin marketplace add xiaolai/claude-plugin-marketplace
claude plugin install rich-terminal@xiaolai
To try it from a checkout instead, run claude --plugin-dir /path/to/claude-rich-terminal.
/rich open draw such diagrams exactly./rich open shows Mermaid's own error.This plugin came out of reading Getting started with Claude Code mods, published on claude.dev on October 1, 2026.
claude plugin validate .claude-plugin/plugin.json
claude plugin test . # hooks and core logic, in Claude Code's test kit
node scripts/check-pages.mjs # browser pages in headless Chrome: drawn, no links, no network
npx tsc -p . && npx tsc -p tsconfig.viewer.json
bash statusline/tests/run.sh # status line scripts: golden output and security contracts
hooks/core.ts (diagrams) and hooks/statusline.ts (the status line's setup) hold the logic that does not touch Claude Code; hooks/register.tsx holds the hooks. The status line itself is the bash in statusline/scripts/; its tests pin the renderer's exact output (bash statusline/tests/run.sh record re-records it) and run under the stock macOS bash 3.2. The renderers are vendored; see THIRD_PARTY_NOTICES.md.
MIT; see LICENSE. The bundled Mermaid renderers are MIT as well; see THIRD_PARTY_NOTICES.md.
hooks/register.tsx 614 lines1import { atom, read, update } from 'claude-code'
2import type { FsStat } from 'claude-code'
3import type { EngineInterface, Register, RenderNode } from 'claude-code'
4
5import {
6 cells,
7 fileUrl,
8 findDiagrams,
9 imageBox,
10 imagePage,
11 inlineArt,
12 listing,
13 MAX_AREA,
14 MAX_SIDE,
15 markdownSafe,
16 needsWholeMarkdown,
17 pageSize,
18 parseCommand,
19 pick,
20 rewrite,
21 scaleFor,
22 segments,
23 viewerHtml,
24} from './core.ts'
25import type { Diagram, PageScripts } from './core.ts'
26import {
27 describeCheck,
28 EXECUTABLE,
29 LEGACY_ACCOUNT_CACHE,
30 LEGACY_SCRIPTS,
31 LEGACY_STATE_FILE,
32 legacyIsPristine,
33 RENDERER,
34 SCRIPTS,
35 scriptState,
36 STATE_FILE,
37 STATUS_HELP,
38 statusCommand,
39 wiring,
40 withStatusLine,
41} from './statusline.ts'
42import type { LegacyScriptName, ScriptName, ScriptState } from './statusline.ts'
43import { renderMermaidAscii } from './vendor/mermaid-ascii.js'
44
45const COMMAND = 'rich'
46const enabled = atom({ plugin: 'rich-terminal', key: 'enabled' } as const, true)
47/** One counter per diagram, bumped when its picture attempt ends: reading it subscribes just that diagram's replies. */
48const attempts = { plugin: 'rich-terminal', key: 'pictureAttempts' } as const
49/** Main-loop turns running now; while one runs, an unclosed fence is still streaming. */
50const running = atom({ plugin: 'rich-terminal', key: 'running' } as const, [] as string[])
51
52const HELP = [
53 '/rich open [n|id] open a diagram in the browser (n counts from the first diagram)',
54 '/rich list number the diagrams of this conversation, with their ids',
55 '/rich on | off draw diagrams in replies, or leave them as source',
56 'With no number, the latest diagram is used.',
57 'Diagram images (Ghostty, kitty): turn on "images" for this plugin in /config.',
58 '',
59 STATUS_HELP,
60].join('\n')
61
62const CHROME_PATHS = [
63 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
64 '/Applications/Chromium.app/Contents/MacOS/Chromium',
65 '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
66 '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
67 '/usr/bin/google-chrome',
68 '/usr/bin/chromium',
69 '/usr/bin/chromium-browser',
70]
71
72/** Retries for a picture that failed, and the wait between them. */
73const RETRIES = 3
74const RETRY_AFTER_MS = 30_000
75const ASCII_CACHE = 200
76
77type Picture = { file: string; width: number; height: number; source: string }
78type Failure = { attempts: number; at: number }
79
80// Module memory: lost on a reload and rebuilt from the cache on disk.
81const pages = new Map<string, { path: string; source: string }>()
82const pictures = new Map<string, Picture | Failure>()
83const rendering = new Set<string>()
84const ascii = new Map<string, { source: string; art: string | undefined }>()
85let queue: Promise<unknown> = Promise.resolve()
86let cacheDir: string | undefined
87/** Why the cache folder was last refused, and when: a refusal is retried after `CACHE_RETRY_MS`. */
88let cacheError: { reason: string; at: number } | undefined
89const CACHE_RETRY_MS = 30_000
90const SYSTEM_TIMEOUT_MS = 20_000
91
92/**
93 * The plugin's cache: under the person's own home, never a shared temporary
94 * folder, and refused when it resolves anywhere else (a planted symlink).
95 */
96async function workDir($: EngineInterface): Promise<string | undefined> {
97 if (cacheDir !== undefined) return cacheDir
98 if (cacheError !== undefined && (await $.clock.now()) - cacheError.at < CACHE_RETRY_MS) return undefined
99 const refuse = async (reason: string) => {
100 cacheError = { reason, at: await $.clock.now() }
101 $.ui.log(`rich-terminal: cache folder refused: ${reason}`, { to: 'debug' })
102 return undefined
103 }
104 const home = await $.env.get('HOME')
105 if (home === undefined || !home.startsWith('/')) return await refuse('HOME is not set')
106 const dir = `${home}/.claude/plugins/data/rich-terminal`
107 try {
108 const base = (await $.fs.stat(home, { resolve: true })).realPath
109 if (base === undefined) return await refuse('the home folder could not be resolved')
110 const inside = (stat: FsStat) => stat.kind === 'dir' && stat.realPath !== undefined && stat.realPath.startsWith(`${base}/`)
111 // Check every existing folder on the way before writing anything: a planted link is never written through.
112 for (let at = dir; at.length > home.length && at.startsWith(`${home}/`); at = at.slice(0, at.lastIndexOf('/'))) {
113 if ((await $.fs.exists(at)) && !inside(await $.fs.stat(at, { resolve: true }))) return await refuse(`${at} leads outside the home folder`)
114 }
115 await $.fs.write(`${dir}/.keep`, '')
116 const where = await $.fs.stat(dir, { resolve: true })
117 if (!inside(where)) return await refuse(`${dir} leads outside the home folder`)
118 // Owner-only, and owned by this user: `$.fs` has neither, so ask the system.
119 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: SYSTEM_TIMEOUT_MS })
120 const chmod = await run(['chmod', '700', where.realPath!])
121 if (chmod.exitCode !== 0) return await refuse(`chmod failed: ${chmod.stderr.trim()}`)
122 const me = await run(['id', '-u'])
123 let owner = await run(['stat', '-f', '%u', where.realPath!]).catch(() => undefined)
124 if (owner === undefined || owner.exitCode !== 0 || !/^\d+$/.test(owner.stdout.trim())) owner = await run(['stat', '-c', '%u', where.realPath!])
125 if (me.exitCode !== 0 || owner.stdout.trim() !== me.stdout.trim()) {
126 return await refuse(`the folder is owned by user ${owner.stdout.trim() || '?'}, not ${me.stdout.trim() || '?'}`)
127 }
128 cacheDir = where.realPath
129 cacheError = undefined
130 return cacheDir
131 } catch (error) {
132 return await refuse(String(error))
133 }
134}
135
136/** True when `path` is a regular file, not a link, inside the cache folder. */
137async function cachedFile($: EngineInterface, dir: string, path: string): Promise<boolean> {
138 try {
139 const stat = await $.fs.stat(path, { resolve: true })
140 return stat.kind === 'file' && !stat.isLink && stat.realPath !== undefined && stat.realPath.startsWith(`${dir}/`)
141 } catch {
142 return false
143 }
144}
145
146function scripts($: EngineInterface, page: 'viewer.js' | 'image.js'): PageScripts {
147 return { mermaid: fileUrl(`${$.plugin.root}/viewer/mermaid.min.js`), page: fileUrl(`${$.plugin.root}/viewer/${page}`) }
148}
149
150/** Writes the diagram's browser page when it is missing and answers its path. */
151async function viewPage($: EngineInterface, diagram: Diagram): Promise<string | undefined> {
152 const dir = await workDir($)
153 if (dir === undefined) return undefined
154 const path = `${dir}/${diagram.id}.html`
155 const known = pages.get(diagram.id)
156 if (known?.path === path && known.source === diagram.source && (await cachedFile($, dir, path))) return path
157 await $.fs.write(path, viewerHtml(diagram, scripts($, 'viewer.js')))
158 if (!(await cachedFile($, dir, path))) return undefined
159 pages.set(diagram.id, { path, source: diagram.source })
160 return path
161}
162
163/** Every diagram in the conversation so far, oldest first. */
164async function diagramsInSession($: EngineInterface): Promise<Diagram[]> {
165 const messages = await $.session.messages()
166 return messages.filter(m => m.role === 'assistant').flatMap(m => findDiagrams(m.text))
167}
168
169function draw(diagram: Diagram, direction?: 'TD'): string | undefined {
170 try {
171 return renderMermaidAscii(diagram.source, { colorMode: 'none', hyperlinks: false, direction })
172 } catch {
173 return undefined
174 }
175}
176
177/** The text drawing for a width, remembered: redraws repeat it often. */
178function textArt(diagram: Diagram, columns: number): string | undefined {
179 const key = `${diagram.id}:${columns}`
180 const known = ascii.get(key)
181 if (known !== undefined && known.source === diagram.source) return known.art
182 const art = inlineArt(diagram, columns, direction => draw(diagram, direction))
183 if (ascii.size >= ASCII_CACHE) ascii.delete(ascii.keys().next().value!)
184 ascii.set(key, { source: diagram.source, art })
185 return art
186}
187
188/**
189 * Whether this terminal draws pictures (the kitty graphics protocol) with
190 * nothing in between that would drop them; elsewhere diagrams stay text.
191 */
192async function showsPictures($: EngineInterface): Promise<boolean> {
193 if ((await $.env.get('TMUX')) !== undefined) return false
194 const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
195 const term = (await $.env.get('TERM')) ?? ''
196 return (
197 program === 'ghostty' ||
198 term === 'xterm-ghostty' ||
199 term === 'xterm-kitty' ||
200 (await $.env.get('KITTY_WINDOW_ID')) !== undefined
201 )
202}
203
204async function findBrowser($: EngineInterface, configured: string): Promise<string | undefined> {
205 for (const path of configured === '' ? CHROME_PATHS : [configured]) {
206 if (await $.fs.exists(path)) return path
207 }
208 return undefined
209}
210
211/**
212 * Runs headless Chrome until `done` holds for its output, then stops it:
213 * Chrome finishes its work at once but may not exit by itself. Fails when
214 * Chrome ends, or 30 s pass, before `done` holds.
215 */
216async function chrome($: EngineInterface, argv: string[], done: (out: { stdout: string; stderr: string }) => boolean): Promise<string> {
217 const child = $.process.spawn({ argv })
218 const out = { stdout: '', stderr: '' }
219 let finished = false
220 const work = (async () => {
221 for await (const chunk of child) {
222 out[chunk.stream] += chunk.text
223 if (done(out)) {
224 finished = true
225 break
226 }
227 }
228 })()
229 const limit = $.clock.sleep(30_000).then(() => {
230 throw new Error('Chrome did not finish within 30 s')
231 })
232 try {
233 await Promise.race([work, limit])
234 } finally {
235 await child.return?.({ code: null, signal: 'SIGTERM' })
236 }
237 if (!finished) throw new Error(`Chrome stopped early: ${out.stderr.trim().split('\n').at(-1) ?? 'no output'}`)
238 return out.stdout
239}
240
241function isPicture(value: unknown, base: string, source: string): value is Picture {
242 if (typeof value !== 'object' || value === null) return false
243 const p = value as Record<string, unknown>
244 const side = (n: unknown) => Number.isSafeInteger(n) && (n as number) >= 1 && (n as number) <= MAX_SIDE
245 return (
246 typeof p.file === 'string' &&
247 p.file.startsWith(`${base}.`) &&
248 p.file.endsWith('.png') &&
249 !p.file.slice(base.length).includes('/') &&
250 side(p.width) &&
251 side(p.height) &&
252 (p.width as number) * (p.height as number) <= MAX_AREA &&
253 p.source === source
254 )
255}
256
257/** Draws the diagram to a PNG with headless Chrome, once per diagram and theme. */
258async function renderPicture($: EngineInterface, diagram: Diagram, browser: string, theme: string): Promise<Picture> {
259 const dir = await workDir($)
260 if (dir === undefined) throw new Error('no private cache folder')
261 const base = `${dir}/${diagram.id}-${theme}`
262 try {
263 const meta: unknown = JSON.parse(await $.fs.read(`${base}.json`))
264 if (isPicture(meta, base, diagram.source) && (await cachedFile($, dir, meta.file))) return meta
265 } catch {
266 // not drawn yet, or the record is unreadable: draw again
267 }
268
269 const page = `${base}.page.html`
270 await $.fs.write(page, imagePage(diagram, theme, scripts($, 'image.js')))
271 const common = [
272 browser,
273 '--headless',
274 '--disable-gpu',
275 '--no-first-run',
276 '--no-default-browser-check',
277 '--disable-extensions',
278 '--disable-background-networking',
279 '--disable-sync',
280 '--disable-component-update',
281 // One profile per session: two sessions drawing at once would contend for its lock.
282 `--user-data-dir=${dir}/chrome-${await $.session.id()}`,
283 '--virtual-time-budget=10000',
284 ]
285 const dom = await chrome($, [...common, '--dump-dom', fileUrl(page)], out => out.stdout.includes('</html>'))
286 const size = pageSize(dom)
287 if ('error' in size) throw new Error(`Mermaid could not draw it: ${size.error}`)
288
289 // A fresh name each time, so a failed screenshot can never pass off an old file.
290 const file = `${base}.${crypto.randomUUID()}.png`
291 await chrome(
292 $,
293 [
294 ...common,
295 '--hide-scrollbars',
296 `--force-device-scale-factor=${scaleFor(size.width, size.height)}`,
297 '--default-background-color=00000000',
298 `--window-size=${size.width},${size.height}`,
299 `--screenshot=${file}`,
300 fileUrl(page),
301 ],
302 out => out.stderr.includes('written to file') || out.stdout.includes('written to file'),
303 )
304 if (!(await cachedFile($, dir, file))) throw new Error('Chrome wrote no picture')
305 const picture = { file, width: size.width, height: size.height, source: diagram.source }
306 await $.fs.write(`${base}.json`, JSON.stringify(picture))
307 return picture
308}
309
310/** Starts drawing a picture in the background; the replies holding it redraw when the attempt ends. */
311function requestPicture($: EngineInterface, diagram: Diagram, browser: string, theme: string, now: number): void {
312 const key = `${diagram.id}-${theme}`
313 const known = pictures.get(key)
314 if (rendering.has(key) || (known !== undefined && 'file' in known)) return
315 if (known !== undefined && (known.attempts >= RETRIES || now - known.at < RETRY_AFTER_MS)) return
316 rendering.add(key)
317 // One Chrome at a time.
318 queue = queue
319 .then(() => renderPicture($, diagram, browser, theme))
320 .then(
321 picture => pictures.set(key, picture),
322 async (error: unknown) => {
323 pictures.set(key, { attempts: (known && 'attempts' in known ? known.attempts : 0) + 1, at: await $.clock.now() })
324 $.ui.log(`rich-terminal: no image for ${diagram.id}: ${String(error)}`, { to: 'debug' })
325 },
326 )
327 .finally(() => {
328 rendering.delete(key)
329 return update($, { ...attempts, id: diagram.id }, n => (n ?? 0) + 1)
330 })
331}
332
333/** Opens a file in the default browser with the platform's own opener. */
334async function openInBrowser($: EngineInterface, path: string): Promise<string | undefined> {
335 const argv = (await $.fs.exists('/usr/bin/open')) ? ['/usr/bin/open', path] : ['xdg-open', path]
336 try {
337 const ran = await $.process.run(argv, { timeoutMs: 5000 })
338 return ran.exitCode === 0 ? undefined : ran.stderr.trim() || `${argv[0]} exited with ${ran.exitCode}`
339 } catch (error) {
340 return `could not run ${argv[0]}: ${String(error)}`
341 }
342}
343
344// ── The status line under the prompt ─────────────────────────────────
345
346/** What this plugin last copied into the config folder, per file name: a copy still equal to it is the plugin's to replace. */
347const INSTALLED_KEY = 'statuslineInstalled'
348
349/** Claude Code's config folder, where settings.json and the copied scripts live. */
350async function configDir($: EngineInterface): Promise<string | undefined> {
351 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('HOME'))?.concat('/.claude')
352 return dir !== undefined && dir.startsWith('/') ? dir.replace(/\/+$/, '') : undefined
353}
354
355async function bundledScripts($: EngineInterface): Promise<Record<ScriptName, string>> {
356 const texts = await Promise.all(SCRIPTS.map(name => $.fs.read(`${$.plugin.root}/statusline/scripts/${name}`)))
357 return Object.fromEntries(SCRIPTS.map((name, i) => [name, texts[i]!])) as Record<ScriptName, string>
358}
359
360async function readOrNothing($: EngineInterface, path: string): Promise<string | undefined> {
361 try {
362 return await $.fs.read(path)
363 } catch {
364 return undefined
365 }
366}
367
368/** The record of what was installed, every file name with text kept: old names included, so an old setup can be recognized. */
369async function installedRecord($: EngineInterface): Promise<Record<string, string>> {
370 const value: unknown = await $.store.get(INSTALLED_KEY)
371 if (typeof value !== 'object' || value === null) return {}
372 return Object.fromEntries(Object.entries(value as Record<string, unknown>).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
373}
374
375/** Copies the named scripts into `dir`, makes the two entry points executable and records what was copied. */
376async function copyScripts($: EngineInterface, dir: string, bundled: Record<ScriptName, string>, names: readonly ScriptName[]): Promise<void> {
377 for (const name of names) await $.fs.write(`${dir}/${name}`, bundled[name])
378 const executable = names.filter(name => EXECUTABLE.includes(name)).map(name => `${dir}/${name}`)
379 if (executable.length > 0) {
380 const chmod = await $.process.run(['chmod', '755', ...executable], { timeoutMs: SYSTEM_TIMEOUT_MS })
381 if (chmod.exitCode !== 0) throw new Error(`chmod failed: ${chmod.stderr.trim()}`)
382 }
383 await $.store.set(INSTALLED_KEY, { ...(await installedRecord($)), ...Object.fromEntries(names.map(name => [name, bundled[name]])) })
384}
385
386/** `$.fs` cannot delete, so the system does; only ever this plugin's own files. */
387async function removeFiles($: EngineInterface, paths: readonly string[]): Promise<void> {
388 if (paths.length === 0) return
389 const rm = await $.process.run(['rm', '-f', '--', ...paths], { timeoutMs: SYSTEM_TIMEOUT_MS })
390 if (rm.exitCode !== 0) throw new Error(`rm failed: ${rm.stderr.trim()}`)
391}
392
393async function scriptStates($: EngineInterface, dir: string, bundled: Record<ScriptName, string>): Promise<Record<ScriptName, ScriptState>> {
394 const record = await installedRecord($)
395 const states = await Promise.all(SCRIPTS.map(async name => scriptState(bundled[name], await readOrNothing($, `${dir}/${name}`), record[name])))
396 return Object.fromEntries(SCRIPTS.map((name, i) => [name, states[i]!])) as Record<ScriptName, ScriptState>
397}
398
399async function legacyCopies($: EngineInterface, dir: string): Promise<Record<LegacyScriptName, string | undefined>> {
400 const texts = await Promise.all(LEGACY_SCRIPTS.map(name => readOrNothing($, `${dir}/${name}`)))
401 return Object.fromEntries(LEGACY_SCRIPTS.map((name, i) => [name, texts[i]])) as Record<LegacyScriptName, string | undefined>
402}
403
404/** settings.json as it will be with statusLine on the renderer; throws on a file that is not a JSON object. */
405async function newSettings($: EngineInterface, dir: string): Promise<string> {
406 return withStatusLine((await readOrNothing($, `${dir}/settings.json`)) ?? '', dir)
407}
408
409/**
410 * Copies the scripts, carries the settings over from an old-named state file,
411 * points statusLine at the renderer, then removes the old-named copies that
412 * are still exactly what this plugin installed. In that order, so a failure
413 * at any step leaves a working status line. `settings` is the settings.json
414 * text to write, made by `newSettings` before anything else is touched.
415 */
416async function installStatusLine($: EngineInterface, dir: string, bundled: Record<ScriptName, string>, settings: string): Promise<void> {
417 await copyScripts($, dir, bundled, SCRIPTS)
418 const home = await $.env.get('HOME')
419 const stateDir = home !== undefined && home.startsWith('/') ? `${home}/.claude` : undefined
420 const oldState = stateDir === undefined ? undefined : await readOrNothing($, `${stateDir}/${LEGACY_STATE_FILE}`)
421 if (stateDir !== undefined && oldState !== undefined && (await readOrNothing($, `${stateDir}/${STATE_FILE}`)) === undefined) {
422 await $.fs.write(`${stateDir}/${STATE_FILE}`, oldState)
423 }
424 await $.fs.write(`${dir}/settings.json`, settings)
425 const record = await installedRecord($)
426 const copies = await legacyCopies($, dir)
427 const ours = LEGACY_SCRIPTS.filter(name => copies[name] !== undefined && copies[name] === record[name])
428 await removeFiles($, [
429 ...ours.map(name => `${dir}/${name}`),
430 ...(stateDir === undefined || oldState === undefined ? [] : [`${stateDir}/${LEGACY_STATE_FILE}`, `${stateDir}/${LEGACY_ACCOUNT_CACHE}`]),
431 ])
432 await $.store.set(INSTALLED_KEY, Object.fromEntries(Object.entries(await installedRecord($)).filter(([name]) => !(LEGACY_SCRIPTS as readonly string[]).includes(name))))
433}
434
435/**
436 * Keeps the status line current at a session start. When statusLine runs this
437 * plugin's renderer, a missing or outdated copy is replaced, a current one is
438 * recorded as the plugin's, and an edited one is left alone. When it runs the
439 * old-named copies and they are exactly what the plugin installed, the setup
440 * moves to the new names; otherwise it waits for /rich status setup.
441 */
442async function refreshStatusScripts($: EngineInterface): Promise<void> {
443 try {
444 const dir = await configDir($)
445 if (dir === undefined) return
446 const wired = wiring((await $.settings.read({ source: 'user' })).statusLine, dir)
447 if (wired === 'other') return
448 const bundled = await bundledScripts($)
449 if (wired === 'legacy') {
450 if (legacyIsPristine(await legacyCopies($, dir), await installedRecord($))) {
451 await installStatusLine($, dir, bundled, await newSettings($, dir))
452 $.ui.log('rich-terminal: status line moved to the rich-status names', { to: 'debug' })
453 }
454 return
455 }
456 const states = await scriptStates($, dir, bundled)
457 const stale = SCRIPTS.filter(name => states[name] === 'missing' || states[name] === 'outdated')
458 const record = await installedRecord($)
459 const adopt = SCRIPTS.filter(name => states[name] === 'current' && record[name] !== bundled[name])
460 if (stale.length > 0 || adopt.length > 0) await copyScripts($, dir, bundled, [...stale, ...adopt])
461 if (stale.length > 0) $.ui.log(`rich-terminal: status line scripts updated: ${stale.join(', ')}`, { to: 'debug' })
462 } catch (error) {
463 $.ui.log(`rich-terminal: status line scripts not refreshed: ${String(error)}`, { to: 'debug' })
464 }
465}
466
467/** Answers `/rich status …`. */
468async function runStatus($: EngineInterface, words: readonly string[]): Promise<string> {
469 const command = statusCommand(words)
470 if (command.kind === 'invalid') return `${command.reason}\n${STATUS_HELP}`
471 if (command.kind === 'controller') {
472 // The bundled controller: it writes only the state file, which the copied renderer reads.
473 const ran = await $.process.run(['bash', `${$.plugin.root}/statusline/scripts/rich-status-ctl.sh`, command.action], { timeoutMs: SYSTEM_TIMEOUT_MS })
474 return ran.exitCode === 0 ? ran.stdout.trim() : `The status line controller failed: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`
475 }
476 const dir = await configDir($)
477 if (dir === undefined) return 'No config folder: neither CLAUDE_CONFIG_DIR nor HOME is an absolute path.'
478 const bundled = await bundledScripts($)
479 if (command.kind === 'check') {
480 const wired = wiring((await $.settings.read({ source: 'user' })).statusLine, dir)
481 return describeCheck(await scriptStates($, dir, bundled), wired, dir)
482 }
483 let settings: string
484 try {
485 settings = await newSettings($, dir)
486 } catch (error) {
487 return `${dir}/settings.json could not be read as JSON, so it was left alone: ${String(error)}`
488 }
489 await installStatusLine($, dir, bundled, settings)
490 return [`Copied ${SCRIPTS.join(', ')} to ${dir}.`, `${dir}/settings.json: statusLine runs ${dir}/${RENDERER}.`, 'The status line appears with the next status update.'].join('\n')
491}
492
493export const register: Register = (on, options) => {
494 const images = options.images === true
495 const theme = options.imageTheme === 'default' ? 'default' : 'dark'
496 const browserPath = typeof options.browser === 'string' ? options.browser.trim() : ''
497 let browser: string | undefined
498
499 on('session.start', async ($, e, next) => {
500 await $.command.register({
501 name: COMMAND,
502 description: 'Mermaid diagrams drawn in replies, and the status line under the prompt',
503 argumentHint: 'open [n] | list | on | off | status [action]',
504 })
505 if (images && (await showsPictures($))) browser = await findBrowser($, browserPath)
506 const result = await next(e)
507 if (e.isInteractive) await refreshStatusScripts($)
508 return result
509 })
510
511 on('turn.start', async ($, e, next) => {
512 await update($, running, ids => [...ids.filter(id => id !== e.turnId), e.turnId].slice(-16))
513 return next(e)
514 })
515
516 on('turn.complete', async ($, e, next) => {
517 try {
518 return await next(e)
519 } finally {
520 await update($, running, ids => ids.filter(id => id !== e.turnId))
521 }
522 })
523
524 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
525 if (e.surface !== 'terminal' || !(await read($, enabled))) return next(e)
526 const diagrams = findDiagrams(e.props.text)
527 if (diagrams.length === 0) return next(e)
528
529 const streaming = (await read($, running)).length > 0
530 // Room for a drawing: the terminal's width less the transcript's margin
531 // and whatever quote or list prefix the diagram sits behind.
532 const columns = (e.viewport?.columns ?? 80) - 6
533 const original = (d: Diagram) => e.props.text.slice(d.start, d.end)
534 const asText = (d: Diagram): string => {
535 // An unclosed fence is a drawing still arriving only while a turn runs.
536 if (!d.closed) return streaming ? '*Drawing Mermaid diagram…*' : original(d)
537 // Too wide even top-to-bottom, or not drawable: leave the source.
538 return textArt(d, columns - Math.max(cells(d.lead), cells(d.prefix))) ?? original(d)
539 }
540
541 if (images && browser !== undefined && !needsWholeMarkdown(e.props.text, diagrams)) {
542 const now = await $.clock.now()
543 const dir = await workDir($)
544 type Part = { text: string } | { diagram: Diagram; picture: Picture }
545 const parts: Part[] = []
546 for (const part of segments(e.props.text, diagrams)) parts.push(await (async (): Promise<Part> => {
547 if (part.kind === 'text') return { text: part.text }
548 if (part.diagram.closed) await $.state.get({ ...attempts, id: part.diagram.id })
549 const picture = part.diagram.closed ? pictures.get(`${part.diagram.id}-${theme}`) : undefined
550 if (picture !== undefined && 'file' in picture) {
551 // Revalidated each draw: the file may have been cleared away since.
552 if (picture.source === part.diagram.source && dir !== undefined && (await cachedFile($, dir, picture.file))) {
553 return { diagram: part.diagram, picture }
554 }
555 pictures.delete(`${part.diagram.id}-${theme}`)
556 }
557 if (part.diagram.closed) requestPicture($, part.diagram, browser!, theme, now)
558 return { text: asText(part.diagram) }
559 })())
560 if (parts.every(part => !('text' in part) || markdownSafe(part.text))) {
561 const { Box, Image, Markdown, Text } = $.ui.resolve(e)
562 const children: RenderNode[] = parts.map((part, i) => {
563 if ('text' in part) return <Markdown text={part.text} />
564 const box = imageBox(part.picture.width, part.picture.height, Math.max(1, columns))
565 return (
566 <Image
567 key={`d${i}`}
568 source={{ file: part.picture.file, format: 'png' }}
569 columns={box.columns}
570 rows={box.rows}
571 alt={`Mermaid ${part.diagram.kind} diagram`}
572 />
573 )
574 })
575 // A tree replaces the engine's drawing whole, bullet and indent included.
576 return (
577 <Box flexDirection="row">
578 <Box width={2} flexShrink={0}>
579 <Text>{e.props.isFirstOfReply ? '⏺' : ' '}</Text>
580 </Box>
581 <Box flexDirection="column" gap={1} flexGrow={1}>
582 {children}
583 </Box>
584 </Box>
585 )
586 }
587 }
588
589 const text = rewrite(e.props.text, diagrams, asText)
590 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
591 })
592
593 on('command.run', { command: COMMAND }, async ($, e) => {
594 const command = parseCommand(e.args)
595 if (command.kind === 'help') return { text: HELP }
596 if (command.kind === 'invalid') return { text: `${command.reason}\n${HELP}` }
597 if (command.kind === 'status') return { text: await runStatus($, command.words) }
598 if (command.kind === 'on' || command.kind === 'off') {
599 await update($, enabled, () => command.kind === 'on')
600 return { text: `Rich terminal is ${command.kind}.` }
601 }
602 const diagrams = await diagramsInSession($)
603 if (command.kind === 'list') return { text: listing(diagrams) }
604
605 const picked = pick(diagrams, command.id)
606 if ('error' in picked) return { text: `${picked.error} Run /${COMMAND} list.` }
607 const { diagram } = picked
608 const page = await viewPage($, diagram)
609 if (page === undefined) return { text: `Could not write the browser page: no private cache folder (${cacheError?.reason ?? 'unknown reason'}). It is retried in 30 s.` }
610 const failed = await openInBrowser($, page)
611 return { text: failed === undefined ? `Opened ${diagram.kind} ${diagram.id} in the browser.` : `Could not open the browser: ${failed}` }
612 })
613}
614hooks/core.ts 502 lines1// Pure logic: no host API in this file, so it runs under plain Node in tests.
2
3export type Diagram = {
4 /** 64-bit digest of `source` as 16 hex characters: the same diagram keeps it across redraws and reloads. */
5 id: string
6 /** `flowchart`, `sequence`, or the diagram's own first keyword. */
7 kind: string
8 /**
9 * The text between the fence lines, with any blockquote or list prefix
10 * removed, line endings normalised to LF and the final newline dropped.
11 */
12 source: string
13 /** Offsets of the whole fenced block, prefixes included, in the text it was found in. */
14 start: number
15 end: number
16 /** False while the closing fence has not arrived and its container has not ended. */
17 closed: boolean
18 /** What the block's first line starts with: blockquote markers, a list marker, indentation; '' at top level. */
19 lead: string
20 /** What each later line of the block starts with. */
21 prefix: string
22}
23
24const FENCE = /^([ ]*)(`{3,}|~{3,})(.*)$/
25const QUOTE = /^[ ]{0,3}>[ ]?/
26const LIST_MARK = /^([ ]{0,3})([-*+]|\d{1,9}[.)])([ ]+|$)/
27const HTML_BLOCK = /^[ ]{0,3}<(pre|script|style|textarea)(?:[\s>]|$)/i
28
29/** Two independent 32-bit streams (cyrb53-style mixing), 16 hex characters. */
30export function hash(text: string): string {
31 let h1 = 0xdeadbeef
32 let h2 = 0x41c6ce57
33 for (let i = 0; i < text.length; i++) {
34 const c = text.charCodeAt(i)
35 h1 = Math.imul(h1 ^ c, 2654435761)
36 h2 = Math.imul(h2 ^ c, 1597334677)
37 }
38 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
39 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
40 return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0')
41}
42
43export function kindOf(source: string): string {
44 const lines = source.split('\n')
45 let i = 0
46 // YAML frontmatter (`---` … `---`) comes before the diagram's header.
47 if (lines[0]?.trim() === '---') {
48 const close = lines.findIndex((line, n) => n > 0 && line.trim() === '---')
49 if (close > 0) i = close + 1
50 }
51 for (; i < lines.length; i++) {
52 const line = lines[i]!.trim()
53 if (line === '' || line.startsWith('%%')) continue
54 const word = line.split(/[\s;]/, 1)[0] ?? ''
55 if (word === 'flowchart' || word === 'graph') return 'flowchart'
56 if (word === 'sequenceDiagram') return 'sequence'
57 return word.replace(/[^A-Za-z0-9-]/g, '') || 'diagram'
58 }
59 return 'diagram'
60}
61
62/** One container a line sits in: a blockquote, or a list item of a given content width. */
63type Container = { kind: 'quote' } | { kind: 'list'; width: number }
64
65type Open = {
66 marker: string
67 containers: Container[]
68 indent: number
69 lead: string
70 prefix: string
71 isMermaid: boolean
72 start: number
73 lines: string[]
74}
75
76/** Reads the container markers at the start of a line: `> `, `- `, `1. `, in any nesting. */
77function readContainers(line: string): { containers: Container[]; lead: string; rest: string } {
78 const containers: Container[] = []
79 let rest = line
80 for (;;) {
81 const quote = QUOTE.exec(rest)
82 if (quote) {
83 containers.push({ kind: 'quote' })
84 rest = rest.slice(quote[0].length)
85 continue
86 }
87 const item = LIST_MARK.exec(rest)
88 if (item && item[3] !== '') {
89 containers.push({ kind: 'list', width: item[0].length })
90 rest = rest.slice(item[0].length)
91 continue
92 }
93 break
94 }
95 return { containers, lead: line.slice(0, line.length - rest.length), rest }
96}
97
98/**
99 * Strips the continuation prefix of `containers` from a later line: `>` for
100 * a quote, the item's width in spaces for a list. Undefined when the line
101 * does not continue them, i.e. a container has ended.
102 */
103function continuation(line: string, containers: readonly Container[]): string | undefined {
104 let rest = line
105 for (const c of containers) {
106 if (c.kind === 'quote') {
107 const quote = QUOTE.exec(rest)
108 if (!quote) return undefined
109 rest = rest.slice(quote[0].length)
110 } else if (rest.trim() === '') {
111 return ''
112 } else if (rest.startsWith(' '.repeat(c.width))) {
113 rest = rest.slice(c.width)
114 } else {
115 return undefined
116 }
117 }
118 return rest
119}
120
121function skipSpaces(text: string, most: number): string {
122 let n = 0
123 while (n < most && text[n] === ' ') n++
124 return text.slice(n)
125}
126
127function finish(open: Open, end: number, closed: boolean): Diagram {
128 const source = open.lines.join('\n')
129 return { id: hash(source), kind: kindOf(source), source, start: open.start, end, closed, lead: open.lead, prefix: open.prefix }
130}
131
132/**
133 * Finds every Mermaid fence in a reply: at top level, and inside any nesting
134 * of blockquotes and list items. A fence quoted inside another code fence, an
135 * HTML comment or a `<pre>`-like block is an example, not a diagram, and is
136 * left alone. A fence inside a container ends where the container does.
137 */
138export function findDiagrams(text: string): Diagram[] {
139 const found: Diagram[] = []
140 let open: Open | undefined
141 let skipUntil: RegExp | undefined
142 /** Content column of the latest list item, for a fence on its own line under it. */
143 let listContent = -1
144 let previousEnd = 0
145
146 const outside = (line: string, here: number): Open | undefined => {
147 const { containers, lead, rest } = readContainers(line)
148 if (skipUntil !== undefined) {
149 if (skipUntil.test(rest)) skipUntil = undefined
150 return undefined
151 }
152 if (/^[ ]{0,3}<!--/.test(rest)) {
153 if (!rest.includes('-->')) skipUntil = /-->/
154 return undefined
155 }
156 const html = HTML_BLOCK.exec(rest)
157 if (html) {
158 const close = new RegExp(`</${html[1]}>`, 'i')
159 if (!close.test(rest)) skipUntil = close
160 return undefined
161 }
162
163 const list = containers.findLast(c => c.kind === 'list')
164 if (list !== undefined) listContent = lead.length
165 else if (line.trim() !== '' && line.length - line.trimStart().length < listContent) listContent = -1
166
167 const match = FENCE.exec(rest)
168 if (!match) return undefined
169 let own = match[1]!.length
170 let effective = containers
171 // Four spaces make indented code, unless the fence sits under the list item above it.
172 if (containers.length > 0 && own > 3) return undefined
173 if (containers.length === 0 && own > 3) {
174 if (!(listContent >= 0 && own <= listContent + 3)) return undefined
175 effective = [{ kind: 'list', width: listContent }]
176 own -= listContent
177 }
178 const info = (match[3] ?? '').trim()
179 // A backtick fence cannot carry a backtick in its info string.
180 if (match[2]!.startsWith('`') && info.includes('`')) return undefined
181 const prefix =
182 effective === containers
183 ? lead.replace(/([-*+]|\d{1,9}[.)])(?=[ ])/g, m => ' '.repeat(m.length)) + ' '.repeat(own)
184 : ' '.repeat(listContent) + ' '.repeat(own)
185 return {
186 marker: match[2]!,
187 containers: effective,
188 indent: own,
189 lead: lead + match[1]!,
190 prefix,
191 isMermaid: /^mermaid$/i.test(info.split(/\s+/, 1)[0] ?? ''),
192 start: here,
193 lines: [],
194 }
195 }
196
197 let offset = 0
198 for (const lineWithEnd of text.split(/(?<=\n)/)) {
199 const line = lineWithEnd.replace(/\r?\n$/, '')
200 const here = offset
201 offset += lineWithEnd.length
202
203 if (open !== undefined) {
204 const rest = continuation(line, open.containers)
205 if (rest === undefined) {
206 // The container ended, and the fence with it.
207 if (open.isMermaid) found.push(finish(open, previousEnd, true))
208 open = outside(line, here)
209 } else {
210 const match = FENCE.exec(skipSpaces(rest, open.indent))
211 if (
212 match &&
213 match[1]!.length <= 3 &&
214 match[2]![0] === open.marker[0] &&
215 match[2]!.length >= open.marker.length &&
216 (match[3] ?? '').trim() === ''
217 ) {
218 if (open.isMermaid) found.push(finish(open, here + line.length, true))
219 open = undefined
220 } else {
221 open.lines.push(skipSpaces(rest, open.indent))
222 }
223 }
224 } else {
225 open = outside(line, here)
226 }
227 previousEnd = here + line.replace(/\r$/, '').length
228 }
229
230 if (open?.isMermaid) found.push(finish(open, text.length, false))
231 return found
232}
233
234/**
235 * Replaces each Mermaid fence with what `replace` gives, re-adding the
236 * fence's container prefixes to its lines; everything else is untouched.
237 */
238export function rewrite(text: string, diagrams: readonly Diagram[], replace: (d: Diagram) => string): string {
239 let out = ''
240 let at = 0
241 for (const d of diagrams) {
242 const block = replace(d)
243 const original = text.slice(d.start, d.end)
244 out += text.slice(at, d.start)
245 out += block === original ? block : block.split('\n').map((line, i) => (i === 0 ? d.lead : d.prefix) + line).join('\n')
246 at = d.end
247 }
248 return out + text.slice(at)
249}
250
251const WIDE =
252 /[ᄀ-ᅟ⺀-가-힣豈-︰--⦆¢-₩\u{20000}-\u{3fffd}]/u
253const ZERO = /^[\p{M}\p{Cf}\p{Cc}]+$/u
254
255let segmenter: Intl.Segmenter | undefined | null = null
256
257function graphemes(text: string): Iterable<string> {
258 if (segmenter === null) {
259 const Segmenter = (globalThis as { Intl?: { Segmenter?: typeof Intl.Segmenter } }).Intl?.Segmenter
260 segmenter = Segmenter === undefined ? undefined : new Segmenter(undefined, { granularity: 'grapheme' })
261 }
262 if (segmenter === undefined) return Array.from(text)
263 return Array.from(segmenter.segment(text), s => s.segment)
264}
265
266/**
267 * Display cells a line takes on a terminal: wide East Asian characters and
268 * emoji count 2, marks and format characters 0, a tab runs to the next stop of 8.
269 */
270export function cells(text: string): number {
271 // Plain printable ASCII is one cell a character: most lines take this path.
272 if (/^[\x20-\x7e]*$/.test(text)) return text.length
273 let n = 0
274 for (const g of graphemes(text)) {
275 if (g === '\t') {
276 n += 8 - (n % 8)
277 continue
278 }
279 if (ZERO.test(g)) continue
280 n += WIDE.test(g) || /\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F|\u20E3/u.test(g) ? 2 : 1
281 }
282 return n
283}
284
285export type Fit = { fits: true; lines: string[] } | { fits: false; needColumns: number; needRows: number }
286
287/** Whether rendered art fits a region; never crops. */
288export function fit(art: string, columns: number, rows: number): Fit {
289 const lines = art.replace(/\s+$/, '').split('\n').map(line => line.replace(/\s+$/, ''))
290 let needColumns = 0
291 for (const line of lines) needColumns = Math.max(needColumns, cells(line))
292 if (needColumns <= columns && lines.length <= rows) return { fits: true, lines }
293 return { fits: false, needColumns, needRows: lines.length }
294}
295
296/**
297 * The diagram as a fenced text block that fits `columns`, trying the
298 * diagram's own layout first and then top-to-bottom; undefined when neither
299 * fits or the renderer cannot draw it.
300 */
301export function inlineArt(diagram: Diagram, columns: number, draw: (direction?: 'TD') => string | undefined): string | undefined {
302 if (columns < 1) return undefined
303 for (const direction of [undefined, 'TD'] as const) {
304 if (direction === 'TD' && diagram.kind !== 'flowchart') break
305 const art = draw(direction)
306 if (art === undefined) return undefined
307 const fitted = fit(art, columns, Number.POSITIVE_INFINITY)
308 if (fitted.fits) return ['```text', ...fitted.lines, '```'].join('\n')
309 }
310 return undefined
311}
312
313export type Command =
314 | { kind: 'help' }
315 | { kind: 'on' }
316 | { kind: 'off' }
317 | { kind: 'open'; id?: string }
318 | { kind: 'list' }
319 | { kind: 'status'; words: string[] }
320 | { kind: 'invalid'; reason: string }
321
322export function parseCommand(args: string): Command {
323 const words = args.trim().split(/\s+/).filter(Boolean)
324 const [verb, id, ...rest] = words
325 if (verb === undefined || verb === 'help') return { kind: 'help' }
326 if (verb === 'status') return { kind: 'status', words: words.slice(1) }
327 if (rest.length > 0) return { kind: 'invalid', reason: 'too many arguments' }
328 if (verb === 'list') return id === undefined ? { kind: 'list' } : { kind: 'invalid', reason: 'too many arguments' }
329 if (verb === 'on' || verb === 'off') return id === undefined ? { kind: verb } : { kind: 'invalid', reason: 'too many arguments' }
330 if (verb === 'open') return id === undefined ? { kind: verb } : { kind: verb, id }
331 return { kind: 'invalid', reason: `unknown command "${verb}"` }
332}
333
334/**
335 * The diagram a command names: by its number in the conversation (1 is the
336 * first), by its id or an unambiguous id prefix, or the latest one.
337 */
338export function pick(diagrams: readonly Diagram[], which: string | undefined): { diagram: Diagram } | { error: string } {
339 const closed = diagrams.filter(d => d.closed)
340 if (closed.length === 0) return { error: 'No diagram in this conversation yet.' }
341 if (which === undefined) return { diagram: closed.at(-1)! }
342 if (/^[1-9]\d{0,4}$/.test(which)) {
343 const diagram = closed[Number(which) - 1]
344 return diagram === undefined ? { error: `No diagram ${which}; there are ${closed.length}.` } : { diagram }
345 }
346 const exact = closed.findLast(d => d.id === which)
347 if (exact !== undefined) return { diagram: exact }
348 const ids = new Set(closed.filter(d => d.id.startsWith(which)).map(d => d.id))
349 if (ids.size === 1) return { diagram: closed.findLast(d => ids.has(d.id))! }
350 return { error: ids.size === 0 ? `No diagram "${which}".` : `"${which}" matches ${ids.size} diagrams; give more of the id.` }
351}
352
353/** One line per diagram, numbered as `pick` counts them, with its id. */
354export function listing(diagrams: readonly Diagram[]): string {
355 const closed = diagrams.filter(d => d.closed)
356 if (closed.length === 0) return 'No diagram in this conversation yet.'
357 return closed
358 .map((d, i) => {
359 const first = d.source.split('\n').find((line, n) => n > 0 && line.trim() !== '')?.trim() ?? ''
360 return `${String(i + 1).padStart(3)} ${d.id} ${d.kind.padEnd(10)} ${first.slice(0, 40)}`
361 })
362 .join('\n')
363}
364
365export type Segment = { kind: 'text'; text: string } | { kind: 'diagram'; diagram: Diagram }
366
367/** The reply cut into prose and diagrams, in order; empty prose is dropped. */
368export function segments(text: string, diagrams: readonly Diagram[]): Segment[] {
369 const out: Segment[] = []
370 let at = 0
371 const prose = (chunk: string) => {
372 const trimmed = chunk.replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '')
373 if (trimmed.trim() !== '') out.push({ kind: 'text', text: trimmed })
374 }
375 for (const d of diagrams) {
376 prose(text.slice(at, d.start))
377 out.push({ kind: 'diagram', diagram: d })
378 at = d.end
379 }
380 prose(text.slice(at))
381 return out
382}
383
384/**
385 * True when cutting the reply into separate Markdown blocks could change its
386 * meaning: link reference or footnote definitions resolve across the whole
387 * document, and a diagram inside a quote or list cannot be lifted out of it.
388 */
389export function needsWholeMarkdown(text: string, diagrams: readonly Diagram[]): boolean {
390 return diagrams.some(d => d.lead !== '') || /^(?:[ ]{0,3}(?:>[ ]?|(?:[-*+]|\d{1,9}[.)])[ ]+))*[ ]{0,3}\[[^\]]{1,999}\]:/m.test(text)
391}
392
393/** True when `Markdown` can draw the text: short enough, no control character but tab and newline. */
394export function markdownSafe(text: string): boolean {
395 // eslint-disable-next-line no-control-regex
396 return text.length <= 10000 && !/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(text)
397}
398
399/**
400 * Terminal cells for a picture `width` by `height` CSS pixels: about as wide
401 * as it would be on a page, never wider than `maxColumns`. A cell is roughly
402 * twice as tall as it is wide.
403 */
404export function imageBox(width: number, height: number, maxColumns: number): { columns: number; rows: number } {
405 let columns = Math.max(1, Math.min(maxColumns, 255, Math.ceil(width / 8)))
406 let rows = Math.max(1, Math.round((columns * height * 0.47) / width))
407 if (rows > 255) {
408 columns = Math.max(1, Math.floor((columns * 255) / rows))
409 rows = 255
410 }
411 return { columns, rows }
412}
413
414/** The largest picture drawn, in CSS pixels per side and in total. */
415export const MAX_SIDE = 4096
416export const MAX_AREA = 8_000_000
417
418/** Reads the size the image page reports in its title: `SIZE 547x450`, within bounds. */
419export function pageSize(dom: string): { width: number; height: number } | { error: string } {
420 const title = /<title>([^<]*)<\/title>/.exec(dom)?.[1] ?? ''
421 const size = /^SIZE (\d{1,5})x(\d{1,5})$/.exec(title)
422 if (!size) return { error: title.replace(/^ERROR /, '') || 'the page did not finish' }
423 const width = Number(size[1])
424 const height = Number(size[2])
425 if (width < 1 || height < 1) return { error: 'the diagram is empty' }
426 if (width > MAX_SIDE || height > MAX_SIDE || width * height > MAX_AREA) {
427 return { error: `the diagram is too large for a picture (${width}×${height})` }
428 }
429 return { width, height }
430}
431
432/** Device pixels per CSS pixel for a screenshot of this size: 2 when it stays within the pixel budget. */
433export function scaleFor(width: number, height: number): 1 | 2 {
434 return width * height * 4 <= MAX_AREA ? 2 : 1
435}
436
437/** A `file:` URL for an absolute path, every segment encoded. */
438export function fileUrl(path: string): string {
439 return `file://${path.split('/').map(encodeURIComponent).join('/')}`
440}
441
442function escapeHtml(text: string): string {
443 return text.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
444}
445
446/** JSON safe inside a `<script>` element. */
447function scriptJson(value: unknown): string {
448 return JSON.stringify(value).replace(/</g, '\\u003c')
449}
450
451/**
452 * Pages load only the plugin's own scripts and draw only inline styles and
453 * data images: nothing a diagram's source names can reach the network.
454 */
455const CSP = "default-src 'none'; script-src file:; style-src 'unsafe-inline'; img-src data:; font-src data:"
456
457export type PageScripts = { mermaid: string; page: string }
458
459/** The browser view: one diagram, fitted to the window, with zoom, pan and source. */
460export function viewerHtml(diagram: Diagram, scripts: PageScripts): string {
461 const title = escapeHtml(`Mermaid ${diagram.kind} · ${diagram.id}`)
462 return `<!doctype html>
463<html lang="en"><head><meta charset="utf-8">
464<meta http-equiv="Content-Security-Policy" content="${CSP}">
465<meta name="viewport" content="width=device-width,initial-scale=1">
466<title>${title}</title>
467<style>
468:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--line:#d0d7de}
469@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--line:#30363d}}
470html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}
471header{display:flex;gap:8px;align-items:center;padding:8px 12px;border-bottom:1px solid var(--line)}
472header b{margin-right:auto}
473button{font:inherit;padding:4px 10px;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit;cursor:pointer}
474button:disabled{opacity:.4;cursor:default}
475#stage{height:calc(100% - 46px);overflow:hidden;cursor:grab;touch-action:none}
476#stage svg{transform-origin:0 0;max-width:none}
477pre{margin:0;padding:16px;height:calc(100% - 78px);overflow:auto;font:13px ui-monospace,monospace;white-space:pre-wrap}
478[hidden]{display:none}
479</style></head><body>
480<header><b>${title}</b>
481<button id="fit">Fit</button><button id="in">+</button><button id="out">−</button><button id="src">Source</button></header>
482<div id="stage"></div><pre id="code" hidden></pre>
483<script type="application/json" id="diagram">${scriptJson({ source: diagram.source })}</script>
484<script src="${escapeHtml(scripts.mermaid)}"></script>
485<script src="${escapeHtml(scripts.page)}"></script>
486</body></html>
487`
488}
489
490/** A page that draws one diagram on a transparent background and reports its size in its title. */
491export function imagePage(diagram: Diagram, theme: string, scripts: PageScripts): string {
492 return `<!doctype html><html><head><meta charset="utf-8">
493<meta http-equiv="Content-Security-Policy" content="${CSP}">
494<title>LOADING</title>
495<style>html,body{margin:0;background:transparent}svg{display:block}</style></head><body>
496<script type="application/json" id="diagram">${scriptJson({ source: diagram.source, theme })}</script>
497<script src="${escapeHtml(scripts.mermaid)}"></script>
498<script src="${escapeHtml(scripts.page)}"></script>
499</body></html>
500`
501}
502hooks/statusline.ts 130 lines1// The status line under the prompt: Claude Code's statusLine setting runs the
2// bundled bash renderer from a stable copy. This file decides; register.tsx acts.
3
4/** The bundled scripts, copied beside each other: the renderer and controller source the lib. */
5export const SCRIPTS = ['rich-status.sh', 'rich-status-ctl.sh', 'rich-status-lib.sh'] as const
6export type ScriptName = (typeof SCRIPTS)[number]
7export const EXECUTABLE: readonly ScriptName[] = ['rich-status.sh', 'rich-status-ctl.sh']
8export const RENDERER: ScriptName = 'rich-status.sh'
9/** The settings the controller writes and the renderer reads, in `$HOME/.claude`. */
10export const STATE_FILE = 'rich-status.state'
11
12/**
13 * The names before 1.0.0, when the scripts kept the names of the standalone
14 * plugin they came from. A setup under these names is moved to the new ones.
15 */
16export const LEGACY_SCRIPTS = ['statusline-command.sh', 'statusline-ctl.sh', 'statusline-lib.sh'] as const
17export type LegacyScriptName = (typeof LEGACY_SCRIPTS)[number]
18export const LEGACY_RENDERER: LegacyScriptName = 'statusline-command.sh'
19export const LEGACY_STATE_FILE = 'statusline.state'
20export const LEGACY_ACCOUNT_CACHE = '.statusline-account'
21
22/** Controller actions `/rich status <action>` passes straight through; none is `status`. */
23export const CONTROLLER_ACTIONS = ['theme', 'style', 'lines', 'toggle', 'hide', 'show', 'bar', 'account', 'reset', 'weather', 'icons'] as const
24type ControllerAction = (typeof CONTROLLER_ACTIONS)[number] | 'status'
25
26export type StatusCommand = { kind: 'controller'; action: ControllerAction } | { kind: 'setup' } | { kind: 'check' } | { kind: 'invalid'; reason: string }
27
28export const STATUS_HELP = [
29 '/rich status show the status line settings',
30 '/rich status setup copy the status line scripts and point the statusLine setting at them',
31 '/rich status check compare the copied scripts with this plugin',
32 '/rich status theme | style | lines | toggle | hide | show | bar | account | reset | weather | icons',
33 ' change one setting (see the README); takes effect on the next status update',
34].join('\n')
35
36export function statusCommand(words: readonly string[]): StatusCommand {
37 const [action, ...rest] = words
38 if (rest.length > 0) return { kind: 'invalid', reason: 'too many arguments' }
39 if (action === undefined) return { kind: 'controller', action: 'status' }
40 if (action === 'setup' || action === 'check') return { kind: action }
41 if ((CONTROLLER_ACTIONS as readonly string[]).includes(action)) return { kind: 'controller', action: action as ControllerAction }
42 return { kind: 'invalid', reason: `unknown status action "${action}"` }
43}
44
45/** The statusLine command for a renderer copied into `dir`; quoted, so a path with a space stays one argument. */
46export function statusLineCommand(dir: string): string {
47 return `bash "${dir}/${RENDERER}"`
48}
49
50/**
51 * Whether a statusLine setting runs the script at `path`, quoted or not: the
52 * path must be a whole argument, so `rich-status.sh.old` or a longer folder
53 * name ending in the same path does not count.
54 */
55export function runsScript(statusLine: unknown, path: string): boolean {
56 if (typeof statusLine !== 'object' || statusLine === null) return false
57 const command = (statusLine as { command?: unknown }).command
58 if (typeof command !== 'string') return false
59 const quoted = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
60 return new RegExp(`(^|[\\s"'])${quoted}(["'\\s]|$)`).test(command)
61}
62
63/** Which renderer the statusLine setting runs: this plugin's, an old-named copy of it, or something else. */
64export type Wiring = 'ours' | 'legacy' | 'other'
65export function wiring(statusLine: unknown, dir: string): Wiring {
66 if (runsScript(statusLine, `${dir}/${RENDERER}`)) return 'ours'
67 return runsScript(statusLine, `${dir}/${LEGACY_RENDERER}`) ? 'legacy' : 'other'
68}
69
70/**
71 * Whether old-named copies are exactly what this plugin installed: the
72 * renderer is there and recorded, and every copy present equals its record.
73 * Only then may they be moved to the new names without asking.
74 */
75export function legacyIsPristine(copies: Readonly<Record<LegacyScriptName, string | undefined>>, record: Readonly<Record<string, string>>): boolean {
76 if (copies[LEGACY_RENDERER] === undefined) return false
77 return LEGACY_SCRIPTS.every(name => copies[name] === undefined || copies[name] === record[name])
78}
79
80/**
81 * settings.json text with statusLine pointing at the renderer in `dir`, every
82 * other key kept, and the statusLine's own other keys (padding,
83 * refreshInterval) kept too. Throws on text that is not a JSON object, so a
84 * broken settings file is reported, never overwritten.
85 */
86export function withStatusLine(text: string, dir: string): string {
87 const settings: unknown = text.trim() === '' ? {} : JSON.parse(text)
88 if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) throw new Error('settings.json does not hold a JSON object')
89 const old = (settings as { statusLine?: unknown }).statusLine
90 const kept = typeof old === 'object' && old !== null && !Array.isArray(old) ? old : {}
91 const statusLine = { ...kept, type: 'command', command: statusLineCommand(dir) }
92 return `${JSON.stringify({ ...settings, statusLine }, null, 2)}\n`
93}
94
95/**
96 * What to do with one copied script, from the bundled text, the copy's text
97 * (undefined when missing) and what this plugin last installed there.
98 *
99 * A copy is replaced only when this plugin put it there and nobody has edited
100 * it since; an edited copy is the person's and is left alone.
101 */
102export type ScriptState = 'current' | 'missing' | 'outdated' | 'customized'
103export function scriptState(bundled: string, copy: string | undefined, installed: string | undefined): ScriptState {
104 if (copy === undefined) return 'missing'
105 if (copy === bundled) return 'current'
106 return copy === installed ? 'outdated' : 'customized'
107}
108
109const STATE_TEXT: Record<ScriptState, string> = {
110 current: 'up to date',
111 outdated: 'older than this plugin; replaced at the next session start',
112 customized: 'edited since it was copied; left alone (/rich status setup replaces it)',
113 missing: 'missing (/rich status setup copies it)',
114}
115
116const WIRING_TEXT: Record<Wiring, string> = {
117 ours: "Claude Code's statusLine setting runs these copies.",
118 legacy: "Claude Code's statusLine setting runs the older statusline-*.sh copies; /rich status setup moves them to these names.",
119 other: "Claude Code's statusLine setting runs something else; /rich status setup points it at these copies.",
120}
121
122/** Two answers: are the copies this plugin's current scripts, and does Claude Code's statusLine setting run them. */
123export function describeCheck(states: Readonly<Record<ScriptName, ScriptState>>, wired: Wiring, dir: string): string {
124 return [
125 `Status line scripts copied into ${dir}:`,
126 ...SCRIPTS.map(name => ` ${name.padEnd(19)} ${STATE_TEXT[states[name]]}`),
127 WIRING_TEXT[wired],
128 ].join('\n')
129}
130hooks/vendor/mermaid-ascii.js 8341 lines1// node_modules/@zombie-mermaid/core/dist/index.js
2function e(e2, t2) {
3 let n2 = e2.match(/^click\s+([\w\p{L}-]+)\s+(.*)$/iu);
4 if (!n2) return;
5 let r = n2[1], i = n2[2].trim(), a2 = { ...t2.get(r) }, o = i.match(/^(?:call|callback)\s+(.+?)\s*$/i);
6 if (o) {
7 let e3 = o[1].match(/^(.*?\))\s+"([^"]*)"\s*$/);
8 e3 ? (a2.callback = e3[1].trim(), a2.tooltip = e3[2]) : a2.callback = o[1].trim(), t2.set(r, a2);
9 return;
10 }
11 i = i.replace(/^href\s+/i, "");
12 let s = [...i.matchAll(/"([^"]*)"/g)].map((e3) => e3[1]);
13 s.length > 0 && (a2.href = s[0]), s.length > 1 && (a2.tooltip = s[1]);
14 let c = i.match(/(_blank|_self|_parent|_top)\s*$/i);
15 c && (a2.target = c[1].toLowerCase()), (a2.href !== void 0 || a2.tooltip !== void 0) && t2.set(r, a2);
16}
17function t(e2) {
18 if (!e2 || /[\x00-\x1F\x7F]/.test(e2)) return;
19 let t2 = e2.trim();
20 if (/^[./#?]/.test(t2)) return t2;
21 let n2 = t2.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/)?.[1];
22 return n2 === void 0 || /^(https?|mailto)$/i.test(n2) ? t2 : void 0;
23}
24var n = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
25function a(e2) {
26 let t2 = n.exec(e2.trim());
27 if (!t2) return null;
28 let r = t2[1];
29 if (r === void 0) return null;
30 let i = r;
31 return (i.length === 3 || i.length === 4) && (i = i.split("").map((e3) => e3 + e3).join("")), {
32 r: parseInt(i.slice(0, 2), 16),
33 g: parseInt(i.slice(2, 4), 16),
34 b: parseInt(i.slice(4, 6), 16),
35 a: i.length === 8 ? parseInt(i.slice(6, 8), 16) / 255 : 1
36 };
37}
38var p = /^[&#](?:#?[0-9]+|#?[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/;
39var m = /* @__PURE__ */ new Set(["&", "#"]);
40function ee(e2) {
41 let t2 = null;
42 for (let n2 = 0; n2 < e2.length; n2++) {
43 let r = e2[n2];
44 if (t2 !== null) {
45 r === t2 && (t2 = null);
46 continue;
47 }
48 if (r === '"' || r === "'") {
49 t2 = r;
50 continue;
51 }
52 if (m.has(r)) {
53 let t3 = p.exec(e2.slice(n2));
54 if (t3) {
55 n2 += t3[0].length - 1;
56 continue;
57 }
58 }
59 if (r === "%" && e2[n2 + 1] === "%") return n2;
60 }
61 return -1;
62}
63function h(e2) {
64 let t2 = [], n2 = "", r = null;
65 for (let i = 0; i < e2.length; i++) {
66 let a2 = e2[i];
67 if (r !== null) {
68 n2 += a2, a2 === r && (r = null);
69 continue;
70 }
71 if (a2 === '"' || a2 === "'") {
72 r = a2, n2 += a2;
73 continue;
74 }
75 if (m.has(a2)) {
76 let t3 = p.exec(e2.slice(i));
77 if (t3) {
78 n2 += t3[0], i += t3[0].length - 1;
79 continue;
80 }
81 }
82 if (a2 === ";") {
83 t2.push(n2), n2 = "";
84 continue;
85 }
86 n2 += a2;
87 }
88 return t2.push(n2), t2;
89}
90function g(e2) {
91 let t2 = [], n2 = e2.split("\n");
92 for (let e3 = 0; e3 < n2.length; e3++) {
93 let r = n2[e3].trim(), i = e3 + 1, a2 = ee(r);
94 if (a2 !== -1 && (r = r.slice(0, a2).trim()), r.length === 0) continue;
95 let o = [];
96 for (let e4 of h(r)) {
97 let t3 = e4.trim();
98 t3.length !== 0 && o.push({
99 text: t3,
100 line: i
101 });
102 }
103 o.length > 0 && t2.push(o);
104 }
105 return t2;
106}
107function _(e2) {
108 return g(e2).flat();
109}
110function te(e2) {
111 let t2 = _(e2)[0]?.text.toLowerCase() ?? "";
112 return /^xychart(?:-beta)?(?:\s|$)/.test(t2) ? "xychart" : /^architecture(?:-beta)?\s*$/.test(t2) ? "architecture" : /^c4(?:context|container|component|dynamic|deployment)\s*$/.test(t2) ? "c4" : /^sequencediagram\s*$/.test(t2) ? "sequence" : /^classdiagram\s*$/.test(t2) ? "class" : /^erdiagram\s*$/.test(t2) ? "er" : "flowchart";
113}
114function y(e2) {
115 return e2 === "TD" || e2 === "TB" || e2 === "LR" || e2 === "BT" || e2 === "RL";
116}
117function ne(e2) {
118 let t2 = e2?.toUpperCase();
119 if (t2 === void 0 || !y(t2)) throw Error(`Invalid direction: "${e2}"`);
120 return t2;
121}
122function b(e2, t2) {
123 return t2 === void 0 || !y(t2) ? e2 : {
124 ...e2,
125 direction: t2
126 };
127}
128var x = /* @__PURE__ */ new Set([
129 "linear",
130 "basis",
131 "natural",
132 "step",
133 "stepBefore",
134 "stepAfter"
135]);
136var S = {
137 theme: "colors come from the caller's bg/fg render options, which are usually CSS variables so a diagram inherits the host page's light/dark. A diagram-supplied theme would hard-code colors that fight it. Mermaid's theme names (default/dark/forest/neutral) also have no equivalent in this renderer's palettes \u2014 pass `bg`/`fg`, or a THEMES entry, instead",
138 securitylevel: "this renderer emits static SVG and never executes diagram-supplied script, so there is no sandbox to configure",
139 defaultrenderer: "ELK is the only layout engine; dagre/elk selection has no effect",
140 fontfamily: "use the `font` render option instead",
141 htmllabels: "labels are always rendered as SVG text; there is no HTML label mode",
142 maxtextsize: "no text-size limit is enforced",
143 startonload: "not a browser auto-render integration"
144};
145var C = /^\s*%%\{\s*(?:init|initialize)\s*:\s*([\s\S]*?)\}%%/i;
146function re(e2) {
147 let t2 = "", n2 = null;
148 for (let r = 0; r < e2.length; r++) {
149 let i = e2[r];
150 if (n2 !== null) {
151 if (i === "\\" && r + 1 < e2.length) {
152 let n3 = e2[r + 1];
153 t2 += n3 === "'" ? "'" : `\\${n3}`, r++;
154 } else i === n2 ? (n2 = null, t2 += '"') : t2 += i === '"' ? '\\"' : i;
155 continue;
156 }
157 if (i === '"' || i === "'") {
158 n2 = i, t2 += '"';
159 continue;
160 }
161 if (/[A-Za-z_$]/.test(i)) {
162 let n3 = r;
163 for (; n3 < e2.length && /[\w$-]/.test(e2[n3]); ) n3++;
164 let i2 = e2.slice(r, n3), a2 = n3;
165 for (; a2 < e2.length && /\s/.test(e2[a2]); ) a2++;
166 if (e2[a2] === ":") {
167 t2 += `"${i2}"`, r = n3 - 1;
168 continue;
169 }
170 t2 += /^(true|false|null)$/i.test(i2) ? i2.toLowerCase() : `"${i2}"`, r = n3 - 1;
171 continue;
172 }
173 t2 += i;
174 }
175 return t2;
176}
177function T(e2) {
178 let t2 = e2.match(C);
179 if (!t2) return;
180 let n2;
181 try {
182 n2 = JSON.parse(re(t2[1]));
183 } catch {
184 return;
185 }
186 if (typeof n2 != "object" || !n2) return;
187 let r = { ignored: [] }, i = { ...n2 };
188 for (let [e3, t3] of Object.entries(i)) {
189 let n3 = e3.toLowerCase();
190 if (n3 === "theme" && typeof t3 == "string") {
191 r.theme = t3, r.ignored.push(e3);
192 continue;
193 }
194 if (n3 === "flowchart" && typeof t3 == "object" && t3) {
195 let e4 = { ...t3 };
196 for (let [t4, n4] of Object.entries(e4)) {
197 if (t4.toLowerCase() === "curve" && typeof n4 == "string") {
198 x.has(n4) && (r.curve = n4);
199 continue;
200 }
201 t4.toLowerCase() in S && r.ignored.push(t4);
202 }
203 continue;
204 }
205 n3 in S && r.ignored.push(e3);
206 }
207 return r;
208}
209function ie(e2) {
210 let t2 = { ignored: [] };
211 for (let n2 of e2) {
212 let e3 = T(n2);
213 e3 && (e3.theme !== void 0 && (t2.theme = e3.theme), e3.curve !== void 0 && (t2.curve = e3.curve), t2.ignored.push(...e3.ignored));
214 }
215 return t2;
216}
217function D(e2) {
218 return e2 >= 4352 && e2 <= 4447 || e2 >= 11904 && e2 <= 12031 || e2 >= 12032 && e2 <= 12255 || e2 >= 12288 && e2 <= 12351 || e2 >= 12352 && e2 <= 12447 || e2 >= 12448 && e2 <= 12543 || e2 >= 12544 && e2 <= 12591 || e2 >= 12592 && e2 <= 12687 || e2 >= 12688 && e2 <= 12799 || e2 >= 12800 && e2 <= 13311 || e2 >= 13312 && e2 <= 19903 || e2 >= 19968 && e2 <= 40959 || e2 >= 44032 && e2 <= 55215 || e2 >= 63744 && e2 <= 64255 || e2 >= 65280 && e2 <= 65376 || e2 >= 65504 && e2 <= 65510 || e2 >= 131072;
219}
220var pe = /\p{Emoji_Presentation}/u;
221var O = /\p{Extended_Pictographic}/u;
222function k(e2) {
223 return e2 >= 9632 && e2 <= 9727;
224}
225function A(e2) {
226 return e2 >= 8596 && e2 <= 8601 || e2 === 8617 || e2 === 8618;
227}
228function j(e2) {
229 if (pe.test(e2)) return true;
230 let t2 = e2.codePointAt(0);
231 return t2 !== void 0 && k(t2) || t2 !== void 0 && A(t2) ? false : O.test(e2);
232}
233function M(e2) {
234 let t2 = e2.codePointAt(0);
235 return t2 === void 0 ? false : D(t2) || j(e2);
236}
237function he(e2) {
238 let t2 = e2.startsWith('"') && e2.endsWith('"') ? e2.slice(1, -1) : e2;
239 return (t2.length >= 2 && t2.startsWith("`") && t2.endsWith("`") ? t2.slice(1, -1) : t2).replace(/<br\s*\/?>/gi, "\n").replace(/\\n/g, "\n").replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, "").replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, "<i>$1</i>").replace(/~~(.+?)~~/g, "<s>$1</s>");
240}
241function ge(e2) {
242 return e2.replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, "");
243}
244function q(e2) {
245 let t2 = e2.replace(/;\s*$/, ""), n2 = {};
246 for (let e3 of t2.split(",")) {
247 let t3 = e3.indexOf(":");
248 if (t3 > 0) {
249 let r = e3.slice(0, t3).trim(), i = e3.slice(t3 + 1).trim();
250 r && i && (n2[r] = i);
251 }
252 }
253 return n2;
254}
255function be(e2, t2) {
256 let n2 = e2.match(/^classDef\s+([\w,-]+)\s+(.+)$/);
257 if (!n2) return false;
258 let r = q(n2[2]);
259 for (let e3 of n2[1].split(",")) {
260 let n3 = e3.trim();
261 n3 && t2.classDefs.set(n3, r);
262 }
263 return true;
264}
265function xe(e2, t2) {
266 let n2 = e2.match(/^class\s+([\w-]+(?:\s*,\s*[\w-]+)*)\s+([\w-]+)\s*;?\s*$/);
267 if (!n2) return false;
268 let r = n2[2];
269 for (let e3 of n2[1].split(",")) t2.classAssignments.set(e3.trim(), r);
270 return true;
271}
272function Se(e2, t2) {
273 let n2 = e2.match(/^cssClass\s+"([^"]*)"\s+([\w-]+)\s*;?\s*$/);
274 if (!n2) return false;
275 let r = n2[2];
276 for (let e3 of n2[1].split(",")) {
277 let n3 = e3.trim();
278 n3 && t2.classAssignments.set(n3, r);
279 }
280 return true;
281}
282function Ce(e2, t2) {
283 let n2 = e2.match(/^style\s+([\w,-]+)\s+(.+)$/);
284 if (!n2) return false;
285 let r = q(n2[2]);
286 for (let e3 of n2[1].split(",").map((e4) => e4.trim())) t2.nodeStyles.set(e3, {
287 ...t2.nodeStyles.get(e3),
288 ...r
289 });
290 return true;
291}
292function we(e2) {
293 let t2 = e2.match(/^(.+?):::([\w][\w-]*)$/);
294 return t2 ? {
295 id: t2[1],
296 className: t2[2]
297 } : { id: e2 };
298}
299
300// node_modules/@zombie-mermaid/mermaid-parser/dist/index.js
301var u = String.raw`[\w-]+`;
302var d = new RegExp(String.raw`^(group|service)\s+(${u})\s*(?:\(([^)]*)\))?\s*(?:\[([^\]]*)\])?(?:\s+in\s+(${u}))?$`);
303var f = new RegExp(String.raw`^junction\s+(${u})(?:\s+in\s+(${u}))?$`);
304var p2 = new RegExp(String.raw`^(${u})(\{group\})?:([LRTB])\s+(<)?--(>)?\s+([LRTB]):(${u})(\{group\})?$`);
305var m2 = new RegExp(String.raw`^align\s+(?:row|column)\s+${u}(?:\s+${u})+$`);
306function h2(e2, t2) {
307 throw Error(`Architecture diagram, line ${e2.line}: ${t2} \u2014 "${e2.text}"`);
308}
309function g2(e2) {
310 return e2?.trim().replace(/^"(.*)"$/, "$1") || void 0;
311}
312function _2(e2) {
313 let t2 = {
314 groups: [],
315 services: [],
316 junctions: [],
317 edges: []
318 }, r = /* @__PURE__ */ new Map(), i = [], a2 = [], o = (e3, t3, n2, a3) => {
319 r.has(t3) && h2(e3, `duplicate id "${t3}"`), a3 === t3 && h2(e3, `"${t3}" cannot be inside itself`), r.set(t3, n2), a3 && i.push({
320 stmt: e3,
321 parent: a3
322 });
323 };
324 for (let r2 = 1; r2 < e2.length; r2++) {
325 let i2 = e2[r2], s2 = i2.text, c = s2.match(d);
326 if (c) {
327 let e3 = c[1], r3 = c[2], a3 = c[4], s3 = c[5];
328 o(i2, r3, e3, s3);
329 let l2 = {
330 id: r3,
331 icon: g2(c[3]),
332 title: a3 === void 0 ? r3 : he(a3.trim()),
333 parent: s3
334 };
335 e3 === "group" ? t2.groups.push(l2) : t2.services.push(l2);
336 continue;
337 }
338 let l = s2.match(f);
339 if (l) {
340 let e3 = l[1], n2 = l[2];
341 o(i2, e3, "junction", n2), t2.junctions.push({
342 id: e3,
343 parent: n2
344 });
345 continue;
346 }
347 let u3 = s2.match(p2);
348 if (u3) {
349 a2.push({
350 stmt: i2,
351 edge: {
352 source: u3[1],
353 sourceGroup: u3[2] !== void 0,
354 sourcePort: u3[3],
355 arrowStart: u3[4] !== void 0,
356 arrowEnd: u3[5] !== void 0,
357 targetPort: u3[6],
358 target: u3[7],
359 targetGroup: u3[8] !== void 0
360 }
361 });
362 continue;
363 }
364 m2.test(s2) || h2(i2, "unrecognized statement");
365 }
366 for (let { stmt: e3, parent: t3 } of i) r.get(t3) !== "group" && h2(e3, `"in ${t3}" must name a declared group`);
367 let s = /* @__PURE__ */ new Map();
368 for (let e3 of t2.groups) e3.parent && s.set(e3.id, e3.parent);
369 for (let e3 of t2.groups) {
370 let t3 = /* @__PURE__ */ new Set([e3.id]);
371 for (let n2 = s.get(e3.id); n2; n2 = s.get(n2)) {
372 if (t3.has(n2)) throw Error(`Architecture diagram: group "${e3.id}" is nested inside itself`);
373 t3.add(n2);
374 }
375 }
376 for (let { stmt: e3, edge: n2 } of a2) {
377 let i2 = [[n2.source, n2.sourceGroup], [n2.target, n2.targetGroup]];
378 for (let [n3, a3] of i2) {
379 let i3 = r.get(n3);
380 i3 || h2(e3, `unknown id "${n3}"`), i3 === "group" && h2(e3, `edges connect services or junctions, not the group "${n3}"`);
381 let o2 = [...t2.services, ...t2.junctions].find((e4) => e4.id === n3)?.parent;
382 a3 && !o2 && h2(e3, `"{group}" needs "${n3}" to be declared inside a group`);
383 }
384 t2.edges.push(n2);
385 }
386 return t2;
387}
388var v = {
389 database: "cylinder",
390 disk: "cylinder",
391 cloud: "stadium",
392 internet: "circle"
393};
394var y2 = /* @__PURE__ */ new Set(["L", "R"]);
395function b2(e2) {
396 let t2 = 0, n2 = 0;
397 for (let r of e2) for (let e3 of [r.sourcePort, r.targetPort]) y2.has(e3) ? t2++ : n2++;
398 return n2 > t2 ? "TB" : "LR";
399}
400function x2(e2, t2) {
401 let [n2, r] = t2 === "LR" || t2 === "RL" ? ["L", "R"] : ["T", "B"];
402 return e2.sourcePort === r || e2.targetPort === n2 ? false : e2.sourcePort === n2 || e2.targetPort === r;
403}
404function S2(e2) {
405 let t2 = /* @__PURE__ */ new Map(), n2 = /* @__PURE__ */ new Map();
406 for (let r2 of e2.services) t2.set(r2.id, {
407 id: r2.id,
408 label: r2.title,
409 shape: r2.icon && v[r2.icon] || "rectangle"
410 }), n2.set(r2.id, r2.parent);
411 for (let r2 of e2.junctions) t2.set(r2.id, {
412 id: r2.id,
413 label: "",
414 shape: "filled-circle"
415 }), n2.set(r2.id, r2.parent);
416 let r = /* @__PURE__ */ new Map();
417 for (let t3 of e2.groups) r.set(t3.id, {
418 id: t3.id,
419 label: t3.title,
420 nodeIds: [],
421 children: []
422 });
423 let i = [];
424 for (let t3 of e2.groups) {
425 let e3 = r.get(t3.id), n3 = t3.parent ? r.get(t3.parent) : void 0;
426 n3 ? n3.children.push(e3) : i.push(e3);
427 }
428 for (let [e3, t3] of n2) r.get(t3 ?? "")?.nodeIds.push(e3);
429 let a2 = b2(e2.edges);
430 return {
431 direction: a2,
432 nodes: t2,
433 edges: e2.edges.map((e3) => {
434 let t3 = (e4, t4) => (t4 ? n2.get(e4) : void 0) ?? e4, r2 = !x2(e3, a2), [i2, o] = r2 ? [t3(e3.source, e3.sourceGroup), t3(e3.target, e3.targetGroup)] : [t3(e3.target, e3.targetGroup), t3(e3.source, e3.sourceGroup)];
435 return {
436 source: i2,
437 target: o,
438 style: "solid",
439 hasArrowStart: r2 ? e3.arrowStart : e3.arrowEnd,
440 hasArrowEnd: r2 ? e3.arrowEnd : e3.arrowStart
441 };
442 }),
443 subgraphs: i,
444 classDefs: /* @__PURE__ */ new Map(),
445 classAssignments: /* @__PURE__ */ new Map(),
446 nodeStyles: /* @__PURE__ */ new Map(),
447 linkStyles: /* @__PURE__ */ new Map(),
448 interactions: /* @__PURE__ */ new Map()
449 };
450}
451var C2 = {
452 c4context: "context",
453 c4container: "container",
454 c4component: "component",
455 c4dynamic: "dynamic",
456 c4deployment: "deployment"
457};
458var ee2 = /^(?:UpdateElementStyle|UpdateRelStyle|UpdateLayoutConfig|LAYOUT_[A-Z_]+|SHOW_LEGEND|SHOW_FLOATING_LEGEND|AddElementTag|AddRelTag|AddBoundaryTag|RoleTag)\b/;
459var te2 = /^(Person|System|Container|Component)(Db|Queue)?(_Ext)?$/;
460var ne2 = /^(?:Boundary|Enterprise_Boundary|System_Boundary|Container_Boundary)$/;
461var w = /^(?:Deployment_Node|Node|Node_L|Node_R)$/;
462var re2 = /^(Rel|BiRel|Rel_U|Rel_Up|Rel_D|Rel_Down|Rel_L|Rel_Left|Rel_R|Rel_Right|Rel_Back|RelIndex)$/;
463var ie2 = {
464 Enterprise_Boundary: "ENTERPRISE",
465 System_Boundary: "SYSTEM",
466 Container_Boundary: "CONTAINER"
467};
468var ae = {
469 Rel_U: "up",
470 Rel_Up: "up",
471 Rel_D: "down",
472 Rel_Down: "down",
473 Rel_L: "left",
474 Rel_Left: "left",
475 Rel_R: "right",
476 Rel_Right: "right"
477};
478function oe(e2) {
479 let t2 = e2.trim(), r = t2.length >= 2 && t2.startsWith('"') && t2.endsWith('"') ? t2.slice(1, -1) : t2;
480 return he(r);
481}
482function se(e2) {
483 let t2 = [], n2 = "", r = false;
484 for (let i of e2) i === '"' && (r = !r), i === "," && !r ? (t2.push(n2), n2 = "") : n2 += i;
485 return r ? null : (t2.push(n2), t2.map((e3) => e3.trim()).filter((e3) => !e3.startsWith("$")).map(oe));
486}
487var ce = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*(\{)?\s*$/;
488function T2(e2, t2) {
489 throw Error(`C4 diagram, line ${e2.line}: ${t2} \u2014 "${e2.text}"`);
490}
491function le(e2) {
492 let t2 = C2[e2[0]?.text.trim().toLowerCase() ?? ""];
493 if (!t2) throw Error(`C4 diagram: expected a header of C4Context, C4Container, C4Component, C4Dynamic or C4Deployment, got "${e2[0]?.text ?? ""}"`);
494 let r = {
495 variant: t2,
496 elements: [],
497 boundaries: [],
498 relationships: []
499 }, i = /* @__PURE__ */ new Set(), a2 = [], o, s = (e3, t3) => (t3 || T2(e3, "missing alias (first argument)"), i.has(t3) && T2(e3, `duplicate alias "${t3}"`), i.add(t3), t3);
500 for (let t3 = 1; t3 < e2.length; t3++) {
501 let i2 = e2[t3], c = i2.text;
502 if (c === "}") {
503 a2.length === 0 && T2(i2, 'unmatched "}"'), a2.pop();
504 continue;
505 }
506 if (c === "{") {
507 o || T2(i2, 'unexpected "{"'), a2.push(o), o = void 0;
508 continue;
509 }
510 o = void 0;
511 let l = c.match(/^title(?:\s+(.*))?$/i);
512 if (l) {
513 r.title = he((l[1] ?? "").trim());
514 continue;
515 }
516 if (ee2.test(c)) continue;
517 let u3 = c.match(ce);
518 u3 || T2(i2, "unrecognized statement");
519 let d2 = u3[1], f2 = u3[3] === "{", p3 = se(u3[2]);
520 p3 || T2(i2, "unbalanced quotes");
521 let m3 = d2.match(te2);
522 if (m3) {
523 f2 && T2(i2, `"${d2}" cannot contain a block`);
524 let e3 = m3[1].toLowerCase(), t4 = m3[2] === "Db" ? "db" : m3[2] === "Queue" ? "queue" : "default", n2 = e3 === "container" || e3 === "component", o2 = s(i2, p3[0]), c2 = {
525 alias: o2,
526 kind: e3,
527 shape: t4,
528 external: m3[3] !== void 0,
529 label: p3[1] || o2
530 }, l2 = n2 ? p3[2] : void 0, u4 = n2 ? p3[3] : p3[2];
531 l2 && (c2.technology = l2), u4 && (c2.description = u4), r.elements.push(c2), a2.at(-1)?.elementAliases.push(o2);
532 continue;
533 }
534 if (ne2.test(d2) || w.test(d2)) {
535 let e3 = w.test(d2), t4 = s(i2, p3[0]), n2 = {
536 alias: t4,
537 label: p3[1] || t4,
538 elementAliases: [],
539 children: []
540 };
541 n2.type = ie2[d2] || (p3[2] && (e3 || d2 === "Boundary") ? p3[2] : e3 ? "node" : "system"), e3 && p3[3] && (n2.description = p3[3]);
542 let c2 = a2.at(-1);
543 c2 ? c2.children.push(n2) : r.boundaries.push(n2), f2 ? a2.push(n2) : o = n2;
544 continue;
545 }
546 if (d2.match(re2)) {
547 let e3 = d2 === "RelIndex" ? p3.slice(1) : p3;
548 (!e3[0] || !e3[1]) && T2(i2, 'a relationship needs "from" and "to" aliases');
549 let t4 = {
550 from: e3[0],
551 to: e3[1],
552 label: e3[2] ?? "",
553 bidirectional: d2 === "BiRel"
554 };
555 e3[3] && (t4.technology = e3[3]), d2 === "Rel_Back" && (t4.reversed = true);
556 let n2 = ae[d2];
557 n2 && (t4.layout = n2), r.relationships.push(t4);
558 continue;
559 }
560 T2(i2, `unknown C4 macro "${d2}"`);
561 }
562 if (a2.length > 0) throw Error(`C4 diagram: boundary "${a2.at(-1).alias}" is missing its closing "}"`);
563 r.variant === "dynamic" && r.relationships.forEach((e3, t3) => {
564 e3.index = String(t3 + 1);
565 });
566 for (let e3 of r.relationships) for (let t3 of [e3.from, e3.to]) if (!i.has(t3)) throw Error(`C4 diagram: relationship refers to undeclared alias "${t3}"`);
567 return r;
568}
569function ue(e2, t2) {
570 let n2 = [];
571 for (let r of e2.split("\n")) {
572 let e3 = "";
573 for (let i of r.split(/\s+/).filter(Boolean)) e3 && e3.length + 1 + i.length > t2 ? (n2.push(e3), e3 = i) : e3 = e3 ? `${e3} ${i}` : i;
574 n2.push(e3);
575 }
576 return n2;
577}
578var de = {
579 person: "Person",
580 system: "Software System",
581 container: "Container",
582 component: "Component"
583};
584function fe(e2) {
585 let t2 = (e2.kind === "container" || e2.kind === "component") && e2.technology ? `: ${e2.technology}` : "";
586 return `[${de[e2.kind]}${t2}]`;
587}
588function pe2(e2) {
589 return e2.type ? `[${e2.type}]` : void 0;
590}
591function me(e2) {
592 let t2 = [], n2 = [e2.index ? `${e2.index}:` : "", e2.label].filter(Boolean).join(" ");
593 return n2 && t2.push(n2), e2.technology && t2.push(`[${e2.technology}]`), t2;
594}
595function he2(e2) {
596 switch (e2.layout) {
597 case "up":
598 return {
599 source: e2.to,
600 target: e2.from,
601 axis: "vertical",
602 explicit: true
603 };
604 case "left":
605 return {
606 source: e2.to,
607 target: e2.from,
608 axis: "horizontal",
609 explicit: true
610 };
611 case "right":
612 return {
613 source: e2.from,
614 target: e2.to,
615 axis: "horizontal",
616 explicit: true
617 };
618 case "down":
619 return {
620 source: e2.from,
621 target: e2.to,
622 axis: "vertical",
623 explicit: true
624 };
625 default:
626 return {
627 source: e2.from,
628 target: e2.to,
629 axis: "vertical",
630 explicit: false
631 };
632 }
633}
634var E = "<\\|--|<\\|\\.\\.|\\*--|o--|-->|--\\*|--o|--\\|>|\\.\\.>|\\.\\.\\|>|<--|<\\.\\.?|--";
635var ge2 = new RegExp(E);
636function _e(t2) {
637 let i = {
638 classes: [],
639 relationships: [],
640 namespaces: [],
641 interactions: /* @__PURE__ */ new Map(),
642 classDefs: /* @__PURE__ */ new Map(),
643 classAssignments: /* @__PURE__ */ new Map(),
644 nodeStyles: /* @__PURE__ */ new Map(),
645 notes: []
646 }, a2 = /* @__PURE__ */ new Map(), u3 = null, d2 = null, f2 = 0, p3;
647 for (let r = 1; r < t2.length; r++) {
648 let h3 = t2[r], g3 = h3.text;
649 if (d2 && f2 > 0) {
650 if (g3 === "}") {
651 f2--, f2 === 0 && (d2 = null, p3 = void 0);
652 continue;
653 }
654 let e2 = g3.match(/^<<(\w+)>>$/);
655 if (e2) {
656 d2.annotation = e2[1];
657 continue;
658 }
659 if (g3.startsWith("<<")) throw Error(`Line ${h3.line}: Malformed class annotation "${g3}" \u2014 expected "<<name>>" (e.g. "<<interface>>").`);
660 D2(d2, g3, h3.line);
661 continue;
662 }
663 if (/^click\s+/i.test(g3)) {
664 e(g3, i.interactions);
665 continue;
666 }
667 let _3 = g3.match(/^note\s+(?:for\s+(\S+)\s+)?"([^"]*)"\s*$/);
668 if (_3) {
669 let e2 = _3[1];
670 i.notes.push({
671 text: he(_3[2]),
672 ...e2 ? { forClass: e2 } : {}
673 });
674 continue;
675 }
676 if (be(g3, i) || Ce(g3, i) || Se(g3, i) || xe(g3, i)) continue;
677 let v2 = g3.match(/^namespace\s+(\S+)\s*\{$/);
678 if (v2) {
679 u3 = {
680 name: v2[1],
681 classIds: []
682 };
683 continue;
684 }
685 if (g3 === "}" && u3) {
686 i.namespaces.push(u3), u3 = null;
687 continue;
688 }
689 let y3 = g3.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*\{$/);
690 if (y3) {
691 let e2 = m3(y3[1], y3[2]);
692 d2 = a2.get(e2) ?? null, f2 = 1, p3 = h3.line;
693 continue;
694 }
695 let b3 = g3.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*$/);
696 if (b3) {
697 m3(b3[1], b3[2]);
698 continue;
699 }
700 let x3 = g3.match(/^class\s+(\S+?)\s*\{\s*(.*?)\s*\}$/);
701 if (x3) {
702 let e2 = m3(x3[1], void 0), t3 = a2.get(e2), n2 = x3[2], r2 = n2.match(/^<<(\w+)>>$/);
703 if (t3 && r2) t3.annotation = r2[1];
704 else if (t3 && n2.startsWith("<<")) throw Error(`Line ${h3.line}: Malformed class annotation "${n2}" \u2014 expected "<<name>>" (e.g. "<<interface>>").`);
705 else t3 && n2 && D2(t3, n2, h3.line);
706 continue;
707 }
708 let S3 = g3.match(/^(\S+?)\s*:\s*(.+)$/);
709 if (S3) {
710 let e2 = S3[2];
711 if (!e2.match(/<\|--|--|\*--|o--|-->|\.\.>|\.\.\|>/)) {
712 D2(O2(a2, S3[1]), e2, h3.line);
713 continue;
714 }
715 }
716 let C3 = be2(g3);
717 if (C3) {
718 C3.from = m3(C3.from, void 0, false), C3.to = m3(C3.to, void 0, false), i.relationships.push(C3);
719 continue;
720 }
721 if (ge2.test(g3)) throw Error(`Line ${h3.line}: Malformed class-diagram relationship "${g3}". Expected "FROM ARROW TO" (optionally with cardinalities and a ": label"), e.g. "Animal <|-- Dog" or 'A "1" --> "*" B : label'. ARROW must be one of <|--, <|.., *--, o--, -->, --*, --o, --|>, ..>, ..|>, <--, <.., or --.`);
722 }
723 if (d2 !== null) throw Error(`Line ${p3}: Unclosed class body for "${d2.id}" \u2014 expected a closing "}" before the diagram ends.`);
724 return i.classes = [...a2.values()], i;
725 function m3(e2, t3, n2 = true) {
726 let { id: o, className: s } = we(e2), c = o, l = t3;
727 if (!l) {
728 let e3 = o.match(/^(.+?)~(\w+)~$/);
729 e3 && (c = e3[1], l = e3[2]);
730 }
731 let d3 = O2(a2, c);
732 return l && (d3.label = `${c}<${l}>`), s && i.classAssignments.set(c, s), n2 && u3 && u3.classIds.push(c), c;
733 }
734}
735function D2(e2, t2, n2) {
736 let r = ye(t2, n2);
737 r && (r.isMethod ? e2.methods.push(r.member) : e2.attributes.push(r.member));
738}
739function O2(e2, t2) {
740 let n2 = e2.get(t2);
741 return n2 || (n2 = {
742 id: t2,
743 label: t2,
744 attributes: [],
745 methods: []
746 }, e2.set(t2, n2)), n2;
747}
748function k2(e2, t2) {
749 return Math.max(0, e2.split(t2).length - 1);
750}
751function ve(e2) {
752 let t2 = k2(e2, "~");
753 if (t2 <= 1) return e2;
754 let n2 = e2, r = false;
755 t2 % 2 != 0 && n2.startsWith("~") && (n2 = n2.slice(1), r = true);
756 let i = [...n2], a2 = i.indexOf("~"), o = i.lastIndexOf("~");
757 for (; a2 !== -1 && o !== -1 && a2 !== o; ) i[a2] = "<", i[o] = ">", a2 = i.indexOf("~"), o = i.lastIndexOf("~");
758 return r && i.unshift("~"), i.join("");
759}
760function A2(e2) {
761 let t2 = e2.split(/(,)/), n2 = [];
762 for (let e3 = 0; e3 < t2.length; e3++) {
763 let r = t2[e3];
764 if (r === "," && e3 > 0 && e3 + 1 < t2.length) {
765 let i = t2[e3 - 1], a2 = t2[e3 + 1];
766 k2(i, "~") === 1 && k2(a2, "~") === 1 && (r = `${i},${a2}`, e3++, n2.pop());
767 }
768 n2.push(ve(r));
769 }
770 return n2.join("");
771}
772function ye(e2, t2) {
773 let n2 = e2.trim().replace(/;$/, "");
774 if (!n2) return null;
775 let r = "", i = n2, a2 = i[0];
776 if ((a2 === "+" || a2 === "-" || a2 === "#" || a2 === "~") && (r = a2, i = i.slice(1).trim()), i.includes("(") && !i.includes(")")) throw Error(`Line ${t2}: Malformed class member "${n2}" \u2014 unclosed "(" in a method signature. Expected e.g. "+eat() void" or "+eat(Food f) void".`);
777 let o = i.match(/^(.+?)\(([^)]*)\)(?:\s*(.+))?$/);
778 if (o) {
779 let e3 = A2(o[1].trim()), t3 = o[2]?.trim(), n3 = t3 ? A2(t3) : void 0, a3 = o[3]?.trim(), s2 = a3 ? A2(a3) : void 0, c2 = e3.endsWith("$") || i.includes("$"), l2 = e3.endsWith("*") || i.includes("*");
780 return {
781 member: {
782 visibility: r,
783 name: e3.replace(/[$*]$/, ""),
784 type: s2 || void 0,
785 isStatic: c2,
786 isAbstract: l2,
787 isMethod: true,
788 params: n3
789 },
790 isMethod: true
791 };
792 }
793 let s = A2(i), c = s.endsWith("$"), l = s.endsWith("*");
794 return {
795 member: {
796 visibility: r,
797 name: s.replace(/[$*]$/, ""),
798 type: void 0,
799 isStatic: c,
800 isAbstract: l,
801 isMethod: false
802 },
803 isMethod: false
804 };
805}
806function be2(e2) {
807 let t2 = e2.match(RegExp(`^(\\S+?)\\s+(?:"([^"]*?)"\\s+)?(${E})\\s+(?:"([^"]*?)"\\s+)?(\\S+?)(?::::([\\w][\\w-]*))?(?:\\s*:\\s*(.+))?$`));
808 if (!t2) return null;
809 let r = t2[1], i = t2[2], a2 = i ? he(i) : void 0, o = t2[3].trim(), s = t2[4], c = s ? he(s) : void 0, l = t2[6] ? `${t2[5]}:::${t2[6]}` : t2[5], u3 = t2[7]?.trim(), d2 = u3 ? he(u3) : void 0, f2 = j2(o);
810 return f2 ? {
811 from: r,
812 to: l,
813 type: f2.type,
814 markerAt: f2.markerAt,
815 label: d2,
816 fromCardinality: a2,
817 toCardinality: c
818 } : null;
819}
820function j2(e2) {
821 switch (e2.trim()) {
822 case "<|--":
823 return {
824 type: "inheritance",
825 markerAt: "from"
826 };
827 case "--|>":
828 return {
829 type: "inheritance",
830 markerAt: "to"
831 };
832 case "<|..":
833 return {
834 type: "realization",
835 markerAt: "from"
836 };
837 case "..|>":
838 return {
839 type: "realization",
840 markerAt: "to"
841 };
842 case "*--":
843 return {
844 type: "composition",
845 markerAt: "from"
846 };
847 case "--*":
848 return {
849 type: "composition",
850 markerAt: "to"
851 };
852 case "o--":
853 return {
854 type: "aggregation",
855 markerAt: "from"
856 };
857 case "--o":
858 return {
859 type: "aggregation",
860 markerAt: "to"
861 };
862 case "-->":
863 return {
864 type: "association",
865 markerAt: "to"
866 };
867 case "<--":
868 return {
869 type: "association",
870 markerAt: "from"
871 };
872 case "..>":
873 return {
874 type: "dependency",
875 markerAt: "to"
876 };
877 case "<..":
878 return {
879 type: "dependency",
880 markerAt: "from"
881 };
882 case "--":
883 return {
884 type: "association",
885 markerAt: "to"
886 };
887 default:
888 return null;
889 }
890}
891function M2(e2) {
892 return `${e2.visibility ? `${e2.visibility} ` : ""}${e2.isMethod ? `${e2.name}(${e2.params || ""})` : e2.name}${e2.type ? `: ${e2.type}` : ""}`;
893}
894function N(e2) {
895 let t2 = {
896 entities: [],
897 relationships: []
898 }, r = /* @__PURE__ */ new Map(), i = null;
899 for (let o = 1; o < e2.length; o++) {
900 let s = e2[o], c = s.text;
901 if (i) {
902 if (c === "}") {
903 i = null;
904 continue;
905 }
906 let e3 = F(c);
907 e3 && i.attributes.push(e3);
908 continue;
909 }
910 let l = c.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);
911 if (l) {
912 t2.direction = ne(l[1]);
913 continue;
914 }
915 let u3 = c.match(/^(\S+?)(?:\[(.+)\])?\s*\{(.*)$/);
916 if (u3) {
917 let e3 = u3[1], t3 = u3[2], a2 = P(r, e3, t3 === void 0 ? void 0 : he(t3.trim().replace(/^["']|["']$/g, ""))), o2 = u3[3].trim(), s2 = o2.endsWith("}"), c2 = s2 ? o2.slice(0, -1).trim() : o2;
918 for (let e4 of c2.split(";").map((e5) => e5.trim()).filter(Boolean)) {
919 let t4 = F(e4);
920 t4 && a2.attributes.push(t4);
921 }
922 s2 || (i = a2);
923 continue;
924 }
925 let d2 = xe2(c, s.line);
926 if (d2) {
927 P(r, d2.entity1), P(r, d2.entity2), t2.relationships.push(d2);
928 continue;
929 }
930 }
931 return t2.entities = [...r.values()], t2;
932}
933function P(e2, t2, n2) {
934 let r = e2.get(t2);
935 return r ? n2 !== void 0 && (r.label = n2) : (r = {
936 id: t2,
937 label: n2 ?? t2,
938 attributes: []
939 }, e2.set(t2, r)), r;
940}
941function F(e2) {
942 let t2 = e2.match(/^(\S+)\s+(\S+)(?:\s+(.+))?$/);
943 if (!t2) return null;
944 let r = t2[1], i = t2[2], a2 = t2[3]?.trim() ?? "", o = [], s, c = a2.match(/"([^"]*)"/);
945 c && (s = he(c[1]));
946 let l = a2.replace(/"[^"]*"/, "").trim();
947 for (let e3 of l.split(/\s+/)) {
948 let t3 = e3.toUpperCase();
949 (t3 === "PK" || t3 === "FK" || t3 === "UK") && o.push(t3);
950 }
951 return {
952 type: r,
953 name: i,
954 keys: o,
955 comment: s
956 };
957}
958function xe2(e2, t2) {
959 let r = e2.match(/^(\S+)\s+(\S*(?:--|\.\.)\S*)\s+(\S+)\s*(?::\s*(.*))?$/);
960 if (!r) return null;
961 let i = r[1], a2 = r[2], o = r[3], s = r[4] !== void 0, c = r[4]?.trim() ?? "", l = a2.match(/^([|o}{]*)(--|\.\.?)([|o}{]*)$/), u3 = l ? Se2(l[1]) : null, d2 = l ? Ce2(l[3]) : null;
962 if (!u3 || !d2) throw Error(`Line ${t2}: Invalid ER relationship cardinality "${a2}" in "${e2}". Left side must be one of ||, |o, }|, }o; right side must be one of ||, o|, |{, o{ (e.g. "||--o{").`);
963 if (!s || c.length === 0) throw Error(`Line ${t2}: ER relationship "${i} ${a2} ${o}" is missing a ": label" \u2014 expected e.g. "${i} ${a2} ${o} : label".`);
964 return {
965 entity1: i,
966 entity2: o,
967 cardinality1: u3,
968 cardinality2: d2,
969 label: he(c.replace(/^["']|["']$/g, "")),
970 identifying: l[2] === "--"
971 };
972}
973function Se2(e2) {
974 return e2 === "||" ? "one" : e2 === "|o" ? "zero-one" : e2 === "}|" ? "many" : e2 === "}o" ? "zero-many" : null;
975}
976function Ce2(e2) {
977 return e2 === "||" ? "one" : e2 === "o|" ? "zero-one" : e2 === "|{" ? "many" : e2 === "o{" ? "zero-many" : null;
978}
979var we2 = new Set("aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen".split(" "));
980var Te = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
981var Ee = /^(?:rgba?|hsla?)\(\s*[\d.%]+(?:[\s,/]+[\d.%]+){2,3}\s*\)$/i;
982function I(e2) {
983 return we2.has(e2.toLowerCase()) || Te.test(e2) || Ee.test(e2);
984}
985function L(e2) {
986 let t2 = e2.trim(), n2 = t2.match(/^((?:rgba?|hsla?)\s*\([^)]*\)|#[0-9a-fA-F]+|\S+)?(.*)$/), r = n2?.[1] ?? "", i = n2?.[2]?.trim() ?? "";
987 return r.toLowerCase() === "transparent" ? { label: i } : r !== "" && I(r) ? {
988 color: r,
989 label: i
990 } : { label: t2 };
991}
992function R(e2) {
993 return e2 === "loop" || e2 === "alt" || e2 === "opt" || e2 === "par" || e2 === "critical" || e2 === "break" || e2 === "rect";
994}
995function z(e2) {
996 if (!R(e2)) throw Error(`Invalid block type: "${e2}"`);
997 return e2;
998}
999var De = /^(.+?)\s*(<<->>|<<-->>|--?>?>)\s*([+-]?)(.+?)\s*:\s*(.+)$/;
1000var Oe = /^(.+?)\s*(<<->>|<<-->>|--?>?>|--?[)x]|--?>>|--?>)\s*([+-]?)(.+?)\s*:\s*(.+)$/;
1001function B(e2) {
1002 let t2 = {
1003 actors: [],
1004 messages: [],
1005 blocks: [],
1006 notes: [],
1007 activations: [],
1008 boxes: []
1009 }, r = {
1010 open: void 0,
1011 membership: /* @__PURE__ */ new Map()
1012 }, i = 0, a2, o = /* @__PURE__ */ new Set(), s = [], c = {
1013 enabled: false,
1014 next: 1,
1015 step: 1
1016 }, l, u3;
1017 for (let d2 = 1; d2 < e2.length; d2++) {
1018 let f2 = e2[d2], p3 = f2.text, m3 = p3.match(/^box(?:\s+(.*))?$/);
1019 if (m3) {
1020 if (r.open !== void 0) throw Error(`Line ${f2.line}: Sequence diagram: a box cannot be nested inside another box \u2014 close the open box with "end" first`);
1021 let { color: e3, label: o2 } = L(m3[1] ?? ""), c2 = {
1022 label: he(o2),
1023 actorIds: []
1024 };
1025 e3 !== void 0 && (c2.color = e3), t2.boxes.push(c2), r.open = t2.boxes.length - 1, i = s.length, a2 = f2.line;
1026 continue;
1027 }
1028 let h3 = p3.match(/^create\s+(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/);
1029 if (h3) {
1030 let e3 = h3[1] === "actor" ? "actor" : "participant", i2 = h3[2];
1031 if (o.has(i2)) throw Error(`Line ${f2.line}: It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior`);
1032 o.add(i2), t2.actors.push({
1033 id: i2,
1034 label: he(h3[3]?.trim() ?? i2),
1035 type: e3
1036 }), U(t2, r, i2, f2.line), l = i2;
1037 continue;
1038 }
1039 let g3 = p3.match(/^destroy\s+(.+)$/);
1040 if (g3) {
1041 let e3 = g3[1].trim();
1042 H(t2, o, r, e3, f2.line), u3 = e3;
1043 continue;
1044 }
1045 let _3 = p3.match(/^autonumber(?:\s+(off|\d+(?:\.\d{1,2})?)(?:\s+(\d+(?:\.\d{1,2})?))?)?$/);
1046 if (_3) {
1047 let e3 = _3[1], t3 = _3[2];
1048 e3 === "off" ? c.enabled = false : (c.enabled = true, c.next = e3 === void 0 ? 1 : Number(e3), c.step = t3 === void 0 ? 1 : Number(t3));
1049 continue;
1050 }
1051 let v2 = p3.match(/^(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/);
1052 if (v2) {
1053 let e3 = v2[1] === "actor" ? "actor" : "participant", i2 = v2[2], a3 = v2[3]?.trim() ?? i2, s2 = he(a3);
1054 o.has(i2) || (o.add(i2), t2.actors.push({
1055 id: i2,
1056 label: s2,
1057 type: e3
1058 })), U(t2, r, i2, f2.line);
1059 continue;
1060 }
1061 let y3 = p3.match(/^Note\s+(left of|right of|over)\s+([^:]+):\s*(.+)$/i);
1062 if (y3) {
1063 let e3 = y3[1].toLowerCase(), i2 = y3[2].trim(), a3 = he(y3[3].trim()), s2 = i2.split(",").map((e4) => e4.trim());
1064 for (let e4 of s2) H(t2, o, r, e4, f2.line);
1065 let c2 = "over";
1066 e3 === "left of" ? c2 = "left" : e3 === "right of" && (c2 = "right"), t2.notes.push({
1067 actorIds: s2,
1068 text: a3,
1069 position: c2,
1070 afterIndex: t2.messages.length - 1
1071 });
1072 continue;
1073 }
1074 let b3 = p3.match(/^(loop|alt|opt|par|critical|break|rect)\s*(.*)$/);
1075 if (b3) {
1076 let e3 = z(b3[1]), r2 = b3[2]?.trim() ?? "", i2 = he(r2);
1077 s.push({
1078 type: e3,
1079 label: i2,
1080 startIndex: t2.messages.length,
1081 dividers: [],
1082 line: f2.line
1083 });
1084 continue;
1085 }
1086 let x3 = p3.match(/^(else|and)\s*(.*)$/);
1087 if (x3 && s.length > 0) {
1088 let e3 = x3[2]?.trim() ?? "", r2 = he(e3);
1089 s[s.length - 1].dividers.push({
1090 index: t2.messages.length,
1091 label: r2
1092 });
1093 continue;
1094 }
1095 if (p3 === "end" && r.open !== void 0 && s.length === i) {
1096 r.open = void 0, a2 = void 0;
1097 continue;
1098 }
1099 if (p3 === "end" && s.length > 0) {
1100 let e3 = s.pop();
1101 t2.blocks.push({
1102 type: e3.type,
1103 label: e3.label,
1104 startIndex: e3.startIndex,
1105 endIndex: Math.max(t2.messages.length - 1, e3.startIndex),
1106 dividers: e3.dividers
1107 });
1108 continue;
1109 }
1110 if (p3 === "end") throw Error(`Line ${f2.line}: Sequence diagram: "end" does not match any open block ("loop"/"alt"/"opt"/"par"/"critical"/"break"/"rect") or "box" \u2014 nothing is currently open to close.`);
1111 let S3 = p3.match(/^(activate|deactivate)\s+(.+)$/);
1112 if (S3) {
1113 let e3 = S3[2].trim();
1114 H(t2, o, r, e3, f2.line), t2.activations.push({
1115 actorId: e3,
1116 kind: S3[1] === "activate" ? "start" : "end",
1117 afterIndex: t2.messages.length - 1
1118 });
1119 continue;
1120 }
1121 let C3 = p3.match(De) ?? p3.match(Oe);
1122 if (C3) {
1123 let e3 = C3[2], n2 = C3[4][0];
1124 if (n2 === ">" || n2 === ")") throw Error(`Line ${f2.line}: Malformed sequence-diagram arrow in "${p3}" \u2014 "${e3}${n2}" is not a recognized arrow. Expected one of: ->, -->, ->>, -->>, -x, --x, -), --), <<->>, <<-->>.`);
1125 ke(t2, o, r, c, C3[1], C3[2], C3[3], C3[4], C3[5], f2.line);
1126 let i2 = t2.messages.length - 1, a3 = t2.messages[i2];
1127 if (l !== void 0) {
1128 if (a3.to !== l) throw Error(`Line ${f2.line}: The created participant ${l} does not have an associated creating message after its declaration. Please check the sequence diagram.`);
1129 V(t2, l).createdAt = i2, l = void 0;
1130 }
1131 if (u3 !== void 0) {
1132 if (a3.from !== u3 && a3.to !== u3) throw Error(`Line ${f2.line}: The destroyed participant ${u3} does not have an associated destroying message after its declaration. Please check the sequence diagram.`);
1133 V(t2, u3).destroyedAt = i2, u3 = void 0;
1134 }
1135 continue;
1136 }
1137 }
1138 if (s.length > 0) {
1139 let e3 = s[s.length - 1];
1140 throw Error(`Line ${e3.line}: Sequence diagram: unclosed "${e3.type}" block \u2014 expected a matching "end" before the diagram ends.`);
1141 }
1142 if (r.open !== void 0) {
1143 let e3 = t2.boxes[r.open];
1144 throw Error(`Line ${a2}: Sequence diagram: unclosed "box${e3.label ? ` ${e3.label}` : ""}" \u2014 expected a matching "end" before the diagram ends.`);
1145 }
1146 return t2;
1147}
1148function V(e2, t2) {
1149 let n2 = e2.actors.find((e3) => e3.id === t2);
1150 if (n2 === void 0)
1151 throw Error(`Sequence diagram: unknown actor "${t2}"`);
1152 return n2;
1153}
1154function H(e2, t2, n2, r, i) {
1155 t2.has(r) || (t2.add(r), e2.actors.push({
1156 id: r,
1157 label: r,
1158 type: "participant"
1159 })), U(e2, n2, r, i);
1160}
1161function U(e2, t2, n2, r) {
1162 let i = t2.open;
1163 if (i === void 0) return;
1164 let a2 = t2.membership.get(n2);
1165 if (a2 !== i) {
1166 if (a2 !== void 0) {
1167 let t3 = e2.boxes[a2].label, o = e2.boxes[i].label;
1168 throw Error(`Line ${r}: A same participant should only be defined in one Box: ${n2} can't be in '${t3}' and in '${o}' at the same time.`);
1169 }
1170 t2.membership.set(n2, i), e2.boxes[i].actorIds.push(n2);
1171 }
1172}
1173function ke(e2, t2, r, i, a2, o, s, c, l, u3) {
1174 H(e2, t2, r, a2, u3), H(e2, t2, r, c, u3);
1175 let d2 = o === "<<->>" || o === "<<-->>", f2 = d2 ? o === "<<-->>" ? "dashed" : "solid" : o.startsWith("--") ? "dashed" : "solid", p3 = o.includes(">>") || o.includes("x") ? "filled" : "open", m3 = o.includes("x"), h3 = {
1176 from: a2,
1177 to: c,
1178 label: he(l.trim()),
1179 lineStyle: f2,
1180 arrowHead: p3
1181 };
1182 m3 && (h3.isLost = true), d2 && (h3.bidirectional = true), s === "+" && (h3.activate = true), s === "-" && (h3.deactivate = true), i.enabled && (h3.seqNumber = i.next, i.next = Math.round((i.next + i.step) * 100) / 100), e2.messages.push(h3);
1183}
1184function je(e2) {
1185 let t2 = {}, n2 = {}, r = [], i, a2 = false;
1186 for (let o of e2) {
1187 let e3 = o.text;
1188 if (/^xychart(-beta)?\b/i.test(e3)) {
1189 /\bhorizontal\b/i.test(e3) && (a2 = true);
1190 continue;
1191 }
1192 let s = e3.match(/^title\s+"([^"]+)"/);
1193 if (s) {
1194 i = s[1];
1195 continue;
1196 }
1197 let c = e3.match(/^x-axis\s+(?:"([^"]*)"\s*)?\[([^\]]+)\]/);
1198 if (c) {
1199 c[1] && (t2.title = c[1]), t2.categories = Me(c[2]).map((e4) => Ne(e4.trim()));
1200 continue;types/index.d.ts 15 lines1export type RichTerminalEnabled = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'rich-terminal': {
6 /** False after `/rich off`: replies are drawn as the host draws them. */
7 enabled: RichTerminalEnabled
8 /** Per diagram id: increments whenever that diagram's picture attempt ends, success or failure, so only the replies holding it redraw. */
9 pictureAttempts: StateFamily<number>
10 /** Ids of the main-loop turns running now: an unclosed fence is still streaming only while one runs. */
11 running: string[]
12 }
13 }
14}
15