SLOPSHOPPER

rich-terminal

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

newrowscommandprocess
★ 1v1.0.0MITupdated 2026-10-05xiaolai/claude-rich-terminal
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rich-terminal
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rich ⎿ rich-terminal: /rich open [n|id] open a diagram in the browser (n counts from the first diagram) ⎿ rich-terminal: /rich list number the diagrams of this conversation, with their ids ⎿ rich-terminal: /rich on | off draw diagrams in replies, or leave them as source ⎿ rich-terminal: With no number, the latest diagram is used. ⎿ rich-terminal: Diagram images (Ghostty, kitty): turn on "images" for this plugin in /config. ⎿ rich-terminal: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Rich Terminal

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.

What it does

  • Draws diagrams in place. Each Mermaid block in a reply, including one inside a quote or list, is replaced by a drawing on screen only. The stored message is unchanged, and ctrl+o shows the source.
  • Fits the terminal. A left-to-right flowchart too wide for the window is redrawn top-to-bottom. If it still does not fit, the source is shown. Changing the terminal's width redraws.
  • Optional pictures. In Ghostty or kitty, diagrams can be drawn as real images instead (see Settings).
  • Full view in the browser. /rich open opens a diagram as a zoomable page that works offline.
  • A status line under the prompt. Folder and git state, model and effort, context with Token Weather, time and cost, account and rate limits, restyled live with /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

<!-- 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 -->

Status line

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

Segments are grouped A / B / C for the multi-line layouts. Each appears only when it has data.

GroupSegmentContent
AFolderProject-relative path (project/sub/dir) inside the session's project, otherwise the ~-abbreviated absolute path
ABranchGreen when the working tree is clean, amber when anything is modified or untracked; ↑n ↓m against the upstream
AChanges+insertions −deletions against HEAD, plus ?n untracked files
BModelModel short name (Fable, Opus, …) and the live reasoning effort
BContextctx 42%, or an 8-cell █████░░░ gauge with bar, with Token Weather around it
BTime · costSession duration (45s, 12m, 1h5m) and total API cost
CAccountThe signed-in account from ~/.claude.json, cached for 180 s: the email's local part, or the whole address
CLimits5-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 %.

Token Weather

The context gauge is led by a forecast icon: ☁ ctx 42%.

Context fillIconNerd Font glyph (icons)Forecast
below 25 %☀U+E30D weather-day_sunnyClear
25–49 %☁U+E312 weather-cloudyCloudy
50–74 %☂U+E319 weather-showersShowers
75–89 %☇U+E31D weather-thunderstormStorm
90 % and up↯U+E351 weather-tornadoCompact 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.

Status line settings

/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.

ActionKeyDefaultEffect
themeTHEME, STYLEplainCycle the look: plain → gray → aurora → sunset → forest; the four gradients draw powerline segments
styleSTYLEplainToggle powerline ↔ plain, keeping the theme
linesLINES1Cycle auto → 1 → 2 → 3 rows; auto measures the terminal
toggle / hide / showHIDDEN0Hide the status line (it prints one blank line), or show it
barSHOW_BAR0Context as a ████░░ gauge instead of ctx N%
accountSHOW_ACCOUNT1Show or hide the account; ACCOUNT_LOCAL=0 in the file shows the whole email
resetSHOW_RESET0Reset countdowns after the 5h/7d figures
weatherSHOW_WEATHER1The Token Weather icon on the context gauge
iconsICONSunicodeWeather 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.

How the status line works

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.

Requirements

RequirementNeeded forWithout it
jqparsing the status JSON: the whole linenearly empty; macOS ships /usr/bin/jq, else brew install jq
bash, standard Unix toolsrunning the scriptsstock macOS bash 3.2 works
A truecolor terminal (Ghostty, iTerm2, kitty, WezTerm)the colorscolors degrade in Terminal.app; layout still works
The U+E0B0 glyph (a Nerd Font; Ghostty bundles the symbols)the powerline looks' arrowsmissing-glyph boxes; plain needs none
Nerd Font weather glyphsicons set to nerdmissing-glyph boxes; keep unicode
gitthe branch and changesthose segments hide
timeout or perlbounding one slow git callgit is bounded only between calls

Status line troubleshooting

SymptomCause and fix
Nothing under the prompt/rich status check shows whether statusLine runs the copied scripts; /rich status setup wires it
Nearly empty linejq is missing
Boxes instead of arrowsa powerline look without the U+E0B0 glyph: use a Nerd Font, or /rich status style for plain
Weather icon looks out of placethe font lacks the symbol: with a Nerd Font, /rich status icons
Toggle seems to do nothingthe 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 editedyou 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/.

Commands

CommandEffect
/rich listNumber 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 offDraw diagrams, or leave replies as Claude Code draws them
/rich statusShow the status line settings
/rich status setupCopy the status line scripts and point the statusLine setting at them
/rich status checkSay 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

Settings

In /config, under this plugin:

SettingDefaultMeaning
imagesoffDraw 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
imageThemedarkMermaid theme for pictures: dark, or default for a light terminal
browseremptyPath 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.

Cache

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.

Install

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.

Limitations

  • A Mermaid block is recognised at top level and inside quotes and list items whose markers sit on the fence's own line or directly above it. Unusual Markdown nesting, such as lazy continuation lines, can leave a block as source or end it early.
  • In a dense text drawing, an edge label can land on another line or a container's border, and an edge can cross a box. The picture and /rich open draw such diagrams exactly.
  • The terminal renderer skips a line it cannot parse and draws the rest; a diagram with a syntax error can therefore be drawn incompletely. /rich open shows Mermaid's own error.
  • A diagram that loads external images draws in the browser without them: the pages block every network request.

Background

This plugin came out of reading Getting started with Claude Code mods, published on claude.dev on October 1, 2026.

Development

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.

License

MIT; see LICENSE. The bundled Mermaid renderers are MIT as well; see THIRD_PARTY_NOTICES.md.

Source 5 files
hooks/register.tsx 614 lines
1import { 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}
614
hooks/core.ts 502 lines
1// 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
502
hooks/statusline.ts 130 lines
1// 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}
130
hooks/vendor/mermaid-ascii.js 8341 lines
1// 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 lines
1export 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