Teaches Claude a small tag language and draws it in its replies: colored spans, callouts, charts, heat maps, flow and sequence diagrams, trees, checklists and…

A Claude Code mod that lets Claude draw in its replies. Claude writes a small tag language alongside its Markdown, and the mod draws it in place: colored spans and badges, callouts, checklists, file trees, bar charts with before → after deltas, braille line charts, heat maps, sparklines, flow and sequence diagrams, timelines, side-by-side panels and inline SVG.
[!NOTE] decorated is experimental. The tags, the guide that teaches them and how often Claude reaches for them may all change between versions. Feedback is the point of this release: a drawing that came out wrong, a terminal it breaks in, a reply Claude over- or under-decorated. Please open an issue; see CONTRIBUTING.md for what helps.
<img src="docs/gallery.gif" width="480" alt="/decorated in Windows Terminal: a banner, colored inline spans and badges, a callout, flow and sequence diagrams, before-and-after bars, a braille line chart, a heat map, a tree and steps side by side, a timeline, key-value pairs, and two animated SVGs: a sun setting behind mountains and a dot tracing a curve">
Tests are <ok>green</ok> after the fix; one <warn>flaky</warn> case remains.
<bars title="Suite time" unit="s" better="lower">
unit: 41 -> 12
integration: 188 -> 140
</bars>
Claude uses the tags only where a picture says more than prose. Wide drawings fall back to a plain list on narrow terminals. Run /decorated to see every tag drawn.
At the prompt of a Claude Code terminal session (v2.1.299 or later):
/plugin install decorated --marketplace thomas-whitaker1/decorated
From a shell:
claude plugin install decorated --marketplace thomas-whitaker1/decorated
The mod teaches Claude its tags through the system prompt, alongside any output style. /decorated off stops the teaching (kept across sessions), /decorated on brings it back. /decorated low, medium (the default) or high sets how much the guide asks Claude to decorate, also kept across sessions: low keeps to colored words and badges, with a block only where it is plainly the clearest form; high draws a block whenever a reply has a shape. Every level teaches the same tags. Where another reply mod teaches tags of the same names, decorated steps aside for it, so one mod teaches and draws them.
The guide is about 860 tokens at every level, sent with each request while the tags are taught. It is cached with the rest of the system prompt; switching the tags on or off, or changing the level, misses the cache for one request. Headless runs (claude -p) get no guide.
The level says how much to decorate in general. Lines in a CLAUDE.md can say which replies get which drawing, for steadier results: in ~/.claude/CLAUDE.md for every project, or in one project's own. Add only the lines you want:
## Decorated replies
When the system prompt carries the "Decorated replies" guide:
- Report test, build and lint results with a `<badge>` per check, and compare
timings or counts with `<bars>`.
- Track multi-step work with `<steps>`, updated as each step finishes.
- Show the files a change touched as a `<tree>` with `+ ` `- ` `~ ` marks.
- Put the one caveat the reader must not miss in a `<callout tone="warn">`.
Without that guide, write plain Markdown: nothing draws the tags.
Keep the condition in the first line. decorated draws tags in a reply even when it is not teaching them, but where nothing draws them the tags show raw. A CLAUDE.md rule without it asks for tags in claude -p output and after /decorated off too.
Claude Code 2.1.299 or later. Builds up to 2.1.296 refuse a prop decorated draws with, so there it teaches Claude no tags and says why on the status line. A plugin manifest cannot declare a minimum version, so the install does not stop you.
Checked on 2.1.309 in October 2026:
| Where | Draws | Notes |
|---|---|---|
| Terminal, fullscreen | yes | SVG animations play |
| Terminal, main screen | yes | SVG animations show their final frame |
| Claude Desktop | yes | SVG drawn natively; the vertical lines of flow, tree and seq drawings show small gaps |
| VS Code extension panel | partly | replies draw; /decorated shows plain text, as the panel draws a command's output itself |
| Phone, through Remote Control | no | replies show as plain text |
| Terminal | Draws | Notes |
|---|---|---|
| Windows Terminal | yes | |
| VS Code integrated terminal | yes | |
| conhost | partly | the fractional blocks of bars, sparklines and meters are missing glyphs |
| kitty 0.41 (Linux) | yes | |
| Ghostty 1.3 (Linux) | yes | |
| tmux 3.2 and 3.5 | yes | 256 colors unless CLAUDE_CODE_TMUX_TRUECOLOR=1 is set: heat maps and SVG gradients band |
| iTerm2 | untested | decorated draws only text: box drawing, braille, half blocks, 24-bit color |
Light and dark themes both read well: tones follow the theme, and heat maps pick text that contrasts with each cell. With NO_COLOR set, every drawing keeps its shape in one color. Drawings wider than the window fall back to a list; at 50 columns nothing overflows.
Other reply mods. A mod seated above decorated that draws a reply itself replaces decorated's drawing; one that rewrites the reply's text or wraps its drawing works with it. A mod seated beneath decorated sees every reply, but on one that holds tags its drawing and any rewrite of the text give way to decorated's: a mod that watches replies (prompt-rail) sees them all, while open-links does not make the links in those replies clickable. Replies without tags reach every mod as usual.
decorated only draws. Its module uses no network, no files ($.fs), no processes ($.process) and no model calls. claude plugin validate lists every engine call it makes: the clock (for animation frames), its /decorated command, the system prompt (to add its guide), the session's version, its own state and store, the status line, and drawing. Across sessions it stores two values: whether /decorated off turned the teaching off, and the level /decorated low|medium|high set.
It reads every reply that holds its tags, so what a reply can ask of it is bounded:
<svg> stops at 80 rows, 4,000 elements, 48 levels of nesting and a fixed amount of raster work, about 0.2 s; past that it draws what it got to;<flow> past 100 nodes or 300 edges is listed instead of laid out, a <tree> draws 32 levels, and callouts and columns nest 8 deep.On Claude Desktop and the VS Code panel an <svg> goes to the surface's own SVG element. decorated writes that markup out again without scripts, event handlers, <style>, <image>, <foreignObject>, or any link or url() pointing outside the drawing, so a drawing loads nothing from elsewhere.
Most of these are in the tables above; this is the whole list in one place.
CLAUDE.md steer it; neither guarantees it. Expect the odd reply with a tag it didn't need, or none where one would have helped./decorated shows its gallery as plain text.CLAUDE_CODE_TMUX_TRUECOLOR=1 is set, so heat maps and SVG gradients band.<svg> draws what it got to, a large <flow> becomes a list.claude -p) is never drawn, so the guide isn't sent there; see the condition above.Bug reports, terminal results and pull requests are all welcome. See CONTRIBUTING.md for how to develop and test the mod. Agents working in the repo read .claude/CLAUDE.md.
MIT
hooks/register.tsx 297 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { DEMO } from './demo'
5import { type Clock, drawBlocks, kitFor } from './draw'
6import { LEVELS, type Level, guideFor } from './guide'
7import { BLOCK_TAGS, INLINE_TAGS, hasMarkup, parseBlocks } from './parse'
8import { animation } from './svg'
9
10// The reply's bullet takes two columns; one more keeps wide drawings off the edge.
11const GUTTER = 3
12
13// ── the guide ───────────────────────────────────────────────────────────
14// The tags are taught by a section of the system prompt, in every session a
15// surface draws. `/decorated off` stops the teaching, in this session and
16// the next; replies already in the transcript keep their drawing.
17// `/decorated low|medium|high` sets how much the guide asks Claude to
18// decorate, kept the same way.
19
20const SECTION = 'decorated:guide'
21// Renders whose replies no one sees drawn: a headless run, the one-line
22// prompt, a teammate's copy of its lead's prompt.
23const UNDRAWN = new Set(['print', 'bare', 'teammate'])
24const TEACH_KEY = 'teach'
25const LEVEL_KEY = 'level'
26
27type Store = { store: { get: (key: string) => Promise<unknown> } }
28
29// Whether the person left the teaching on.
30async function isTeaching($: Store): Promise<boolean> {
31 return (await $.store.get(TEACH_KEY)) !== false
32}
33
34const isLevel = (value: unknown): value is Level => LEVELS.includes(value as Level)
35
36// The level the person set; medium where they set none.
37async function levelOf($: Store): Promise<Level> {
38 const level = await $.store.get(LEVEL_KEY)
39
40 return isLevel(level) ? level : 'medium'
41}
42
43// The oldest build that draws decorated's trees: up to 2.1.296 the engine
44// refuses Text's `mono`, and a reply there would show its tags raw.
45const FLOOR = [2, 1, 299] as const
46const TOO_OLD = `decorated is off: it needs Claude Code ${FLOOR.join('.')} or later`
47
48// Whether the engine is older than FLOOR. One that cannot say is; a build
49// not spelled as a release is taken to be new enough.
50async function isTooOld($: { session: { version: () => Promise<{ base?: string }> } }): Promise<boolean> {
51 let base: string | undefined
52 try {
53 base = (await $.session.version()).base
54 } catch {
55 return true
56 }
57 const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(base ?? '')?.slice(1).map(Number)
58 if (!parts) return false
59 for (const [i, floor] of FLOOR.entries()) if (parts[i] !== floor) return parts[i]! < floor
60
61 return false
62}
63
64const SWITCHED = {
65 on: 'Tags on: Claude is taught them again from the next request.',
66 off: 'Tags off: Claude is no longer taught them, from the next request. Replies already drawn keep their drawing.',
67}
68const LEVELLED: Record<Level, string> = {
69 low: 'Level low: Claude is taught to decorate lightly, from the next request.',
70 medium: 'Level medium: Claude is taught to decorate where a tag shows more than prose, from the next request.',
71 high: 'Level high: Claude is taught to draw a block whenever a reply has a shape, from the next request.',
72}
73const UNCACHED = ' The system prompt changes, so that request is not served from the prompt cache.'
74const STILL_OFF = ' Tags are off; `/decorated on` teaches them again.'
75
76// ── another reply mod ───────────────────────────────────────────────────
77// The tag names stay bare. Where another plugin's section of the prompt
78// teaches tags of the same names, decorated steps aside: it teaches nothing
79// and leaves the replies to that mod's drawing, so Claude learns one set of
80// tags and one mod draws them.
81
82const OUR_TAG = new RegExp(`<(${[...INLINE_TAGS, ...BLOCK_TAGS].join('|')})[\\s/>]`, 'g')
83// Two of our names taught as tags make a guide to the same tags, not a mention.
84const SHARED_ENOUGH = 2
85
86// The plugin whose section teaches our tags, if one does.
87function rivalIn(sections: readonly { id: string; text: string }[]): string | undefined {
88 for (const { id, text } of sections) {
89 const owner = /^([^:]+):/.exec(id)?.[1]
90 if (!owner || owner === 'decorated') continue
91 const shared = new Set([...text.matchAll(OUR_TAG)].map(m => m[1]))
92 if (shared.size >= SHARED_ENOUGH) return owner
93 }
94
95 return undefined
96}
97
98// The plugin decorated last stepped aside for; the reply hook reads it.
99let yieldedTo: string | undefined
100// What the status line last said.
101let shownStatus: string | undefined
102
103// ── animation ───────────────────────────────────────────────────────────
104// An animated <svg> plays where a reply is drawn again in place: the
105// terminal's fullscreen layout, while the viewport shows it. Each frame is a
106// write of `tick`, which redraws only the replies that read it. A playing
107// drawing marks itself at every frame it draws and the ticker writes only
108// while a mark is fresh, so a drawing scrolled away or cleared stops it by
109// itself. A finite animation plays to its end; endless loops play on, but
110// only the newest reply's: an older one rests on its still.
111
112const tick = atom({ plugin: 'decorated', key: 'tick' } as const, 0)
113const FRAME_MS = 100
114// A drawing that has drawn no frame for this long is no longer playing.
115const STALE_MS = 2000
116
117// Module state, so a reload replays what is on screen. A drawing (by its
118// markup) → when it was first drawn whole; a looping drawing → its group,
119// the start of the first loop drawn in its reply; a playing drawing → when
120// it last drew a frame.
121const begun = new Map<string, number>()
122const loopGroup = new Map<string, number>()
123let newestGroup = -Infinity
124const playing = new Map<string, number>()
125
126// The value `key` holds, set to `value` the first time it is asked.
127function remember(map: Map<string, number>, key: string, value: number): number {
128 const known = map.get(key)
129 if (known !== undefined) return known
130 map.set(key, value)
131 if (map.size > 256) map.delete(map.keys().next().value!)
132
133 return value
134}
135
136function canAnimate(e: { surface: string; viewport?: { isFullscreen?: boolean }; props: object }): boolean {
137 return e.surface === 'terminal' && e.viewport?.isFullscreen === true && (e.props as { onScreen?: unknown }).onScreen !== null
138}
139
140// A clock for one reply's drawing pass, and whether anything it timed plays.
141function player(now: number): { clock: Clock; isPlaying: () => boolean } {
142 let isPlaying = false
143 // The reply's loops share the group of the first one drawn.
144 let group: number | undefined
145 const clock: Clock = (body, attrs) => {
146 const info = animation(body)
147 if (!info) return undefined
148 const key = JSON.stringify([body, attrs])
149 const start = remember(begun, key, now)
150 if (info.endMs === Infinity) {
151 group ??= loopGroup.get(key) ?? start
152 newestGroup = Math.max(newestGroup, remember(loopGroup, key, group))
153 if (loopGroup.get(key) !== newestGroup) return undefined
154 } else if (now >= start + info.endMs) return undefined
155 playing.set(key, now)
156 isPlaying = true
157
158 return (now - start) / 1000
159 }
160
161 return { clock, isPlaying: () => isPlaying }
162}
163
164// A player at the time now; none where the clock cannot be read, so the
165// drawing rests on its still rather than the reply going undrawn.
166async function playerNow($: { clock: { now: () => Promise<number> } }): Promise<ReturnType<typeof player> | undefined> {
167 try {
168 return player(await $.clock.now())
169 } catch {
170 return undefined
171 }
172}
173
174const ANIMATES = /<(?:animate|set|animateTransform|animateMotion|animateColor)[\s/>]/
175
176export const register: Register = on => {
177 on('session.start', async ($, e, next) => {
178 await $.command.register({
179 name: 'decorated',
180 description: 'Show every markup decorated draws; on or off turns the teaching of its tags, low, medium or high sets how much',
181 argumentHint: '[on|off|low|medium|high]',
182 })
183 // Each frame redraws the replies that read `tick`; the write after a
184 // play ends lets its reply draw the still and stop reading.
185 $.clock.every(FRAME_MS, () => {
186 if (playing.size === 0) return
187 void (async () => {
188 const now = await $.clock.now()
189 for (const [key, drawn] of playing) if (now - drawn > STALE_MS) playing.delete(key)
190 if (playing.size > 0) await update($, tick, () => now)
191 })()
192 })
193
194 return next(e)
195 })
196
197 on('prompt.compose', async ($, e, next) => {
198 const composed = await next(e)
199 if (e.surfaces.length === 0 || e.traits.some(t => UNDRAWN.has(t))) return composed
200 const tooOld = await isTooOld($)
201 // Composed again through every other plugin, to see one above decorated too.
202 const rival = tooOld
203 ? undefined
204 : (rivalIn(composed.sections) ?? rivalIn((await $.prompt.compose(e).catch(() => composed)).sections))
205 yieldedTo = rival
206 const status = tooOld ? TOO_OLD : rival && `decorated steps aside: ${rival} teaches tags of the same names`
207 if (status !== shownStatus) {
208 shownStatus = status
209 $.ui.status(status)
210 }
211 if (tooOld || rival || !(await isTeaching($))) return composed
212
213 return { sections: [...composed.sections, { id: SECTION, text: guideFor(await levelOf($)), scope: 'session' as const }] }
214 }).catch(($, e, next) => next(e))
215
216 on('command.run', { command: 'decorated' }, async ($, e) => {
217 const arg = e.args.trim().toLowerCase()
218 if (arg === 'on' || arg === 'off') {
219 const was = await isTeaching($)
220 const teaching = arg === 'on'
221 await $.store.set(TEACH_KEY, teaching)
222
223 return {
224 text: SWITCHED[arg] + (was === teaching ? '' : UNCACHED),
225 context: [`The person turned decorated's tags ${arg}; nothing to act on.`],
226 }
227 }
228 if (isLevel(arg)) {
229 const was = await levelOf($)
230 await $.store.set(LEVEL_KEY, arg)
231 const teaching = await isTeaching($)
232
233 return {
234 text: LEVELLED[arg] + (teaching && was !== arg ? UNCACHED : '') + (teaching ? '' : STILL_OFF),
235 context: [`The person set decorated's level to ${arg}; nothing to act on.`],
236 }
237 }
238
239 return {
240 text: DEMO,
241 context: ['That was a rendering demo of the decorated mod; nothing to act on.'],
242 }
243 })
244
245 on('ui.render', { component: 'CommandOutput', props: { command: 'decorated' } }, async ($, e, next) => {
246 if (e.props.isErrored) return next(e)
247 const T = $.ui.resolve(e)
248 const { Box, Text } = T
249 const avail = (e.viewport?.columns ?? 80) - GUTTER
250 // The engine heads a plugin's output with its name on the first line,
251 // which would hide a block tag opening that line: draw the name apart.
252 const label = /^([\w-]+):[ \t]*/.exec(e.props.text)
253 const text = label ? e.props.text.slice(label[0].length) : e.props.text
254 const play = canAnimate(e) && ANIMATES.test(text) ? await playerNow($) : undefined
255 const drawn = drawBlocks(kitFor(T, e.surface), parseBlocks(text), avail, play?.clock)
256 if (play?.isPlaying()) await read($, tick)
257
258 return (
259 <Box flexDirection="column" gap={1}>
260 {label && <Text dimColor>{label[1]}</Text>}
261 {drawn}
262 </Box>
263 )
264 }).catch(($, e, next) => next(e))
265
266 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
267 if (yieldedTo || e.props.isSummary || !hasMarkup(e.props.text)) return next(e)
268 // The plugins beneath still see the reply, for what they do besides
269 // drawing it (one records where replies are); their drawing gives way to
270 // decorated's. A rewrite of the text beneath does not reach it.
271 await next(e).catch(() => undefined)
272 const T = $.ui.resolve(e)
273 const { Box, Text } = T
274 const avail = (e.viewport?.columns ?? 80) - GUTTER
275 const play = canAnimate(e) && ANIMATES.test(e.props.text) ? await playerNow($) : undefined
276 const body = (
277 <Box flexDirection="column" gap={1}>
278 {drawBlocks(kitFor(T, e.surface), parseBlocks(e.props.text), avail, play?.clock)}
279 </Box>
280 )
281 // Read after drawing, so only a reply with something playing subscribes.
282 if (play?.isPlaying()) await read($, tick)
283 if (e.surface !== 'terminal') return body
284
285 return (
286 <Box flexDirection="row">
287 <Box minWidth={2} flexShrink={0}>
288 <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
289 </Box>
290 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
291 {body}
292 </Box>
293 </Box>
294 )
295 }).catch(($, e, next) => next(e))
296}
297hooks/demo.ts 91 lines1// What `/decorated` prints: one of everything, drawn by the same hook.
2
3export const DEMO = `<banner sub="markup in replies, drawn by a mod">decorated</banner>
4
5Inline: build is <ok>green</ok>, <err>2 flaky</err>, <warn>1 skipped</warn>, <info>see notes</info>, <dim>an aside</dim>, <hl>look here</hl>, <accent>accent</accent>, <kbd>Ctrl+O</kbd>, <badge tone="ok">PASS</badge> <badge tone="err">FAIL</badge>, <grad>a gradient run</grad>, coverage <meter value="0.82"/>, latency <spark>3 5 4 8 6 9 12 7 5 4</spark>.
6
7<callout tone="tip" title="Callouts">
8Hold any **markdown**, and other markup: <ok>like this</ok>.
9</callout>
10
11<flow title="flow: a layered graph">
12parse -> typecheck -> emit
13parse -> lint {warn}
14lint -> report
15emit -> report {ok}
16</flow>
17
18<seq title="seq: a sequence diagram">
19Browser -> API: POST /login
20API -> Auth: verify(token)
21Auth --> API: ok
22API -> API: mint session
23API --> Browser: 200 + cookie {ok}
24</seq>
25
26<bars title="bars: before → after" unit="kB" better="lower">
27main.js: 412 -> 318
28vendor.js: 1204 -> 1190
29styles.css: 88 -> 97
30</bars>
31
32<chart title="chart: braille lines" unit="ms" height="6" x="Mon,Tue,Wed,Thu,Fri">
33api: 120 130 125 180 160 140 135 128 122 119
34web: 80 85 82 90 140 110 95 90 85 82
35</chart>
36
37<heat title="heat: a heat map" cols="M,T,W,T,F,S,S">
38wk1: 3 5 2 8 1 0 0
39wk2: 4 6 7 9 3 1 0
40wk3: 2 2 5 4 12 0 2
41</heat>
42
43<cols>
44<col title="tree">
45<tree>
46src/
47 parse.ts # tag scanner
48 widgets/
49 + flow.ts
50 ~ bars.ts
51 - old.ts
52</tree>
53</col>
54<col title="steps">
55<steps>
56[x] Bump version
57[~] Build — 3 of 5 targets
58[!] Sign binary — cert expired
59[ ] Publish
60</steps>
61</col>
62</cols>
63
64<timeline>
652026-09-01 | Kickoff
662026-10-01 | Beta {warn}
672026-10-22 | GA {ok}
68</timeline>
69
70<kv>
71Branch: main
72Commit: \`7e11040\`
73Status: <ok>clean</ok>
74</kv>
75
76<svg title="svg: half-block pixels, real-glyph labels, SMIL" viewBox="0 0 120 60" cols="48">
77<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#22305a"/><stop offset="1" stop-color="#e98b4a"/></linearGradient></defs>
78<rect width="120" height="60" rx="6" fill="url(#sky)"/>
79<circle cx="88" cy="26" r="14" fill="#ffd36b"><animate attributeName="cy" from="26" to="46" dur="4s" fill="freeze"/></circle>
80<path d="M0 60 L28 30 L46 46 L70 22 L104 60 Z" fill="#2f5d8a"/>
81<path d="M0 60 Q30 46 60 54 T120 52 V60 Z" fill="#48968c"/>
82<text x="10" y="14" fill="#f5f5f5" font-weight="bold">dusk</text>
83</svg>
84
85<svg title="svg mode=braille: line art" viewBox="0 0 200 60" mode="braille" cols="48">
86<path d="M4 50 C40 0 80 60 120 30 S180 0 196 40" fill="none" stroke="accent" stroke-width="2"/>
87<circle r="4" fill="none" stroke="ok" stroke-width="2"><animateMotion path="M4 50 C40 0 80 60 120 30 S180 0 196 40" dur="3s" repeatCount="indefinite"/></circle>
88</svg>
89
90<rule tone="accent">end of demo</rule>`
91hooks/draw.tsx 209 lines1// Turns parsed blocks into the surface's elements: markdown goes to the
2// engine's own Markdown, everything decorated is rows of Text.
3
4import type { Elements, RenderNode } from 'claude-code'
5
6import { type Attrs, type Block, parseBlocks, rich, richRows } from './parse'
7import { nativeSvg, svg } from './svg'
8import { type Row, type Run, tone } from './text'
9import { WIDGETS, bannerParts } from './widgets'
10
11// A surface that draws SVG itself (desktop, the editor, mobile) carries `Svg`.
12export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Markdown'> & {
13 Svg?: Elements['desktop']['Svg']
14 isTerminal?: true
15 // Theme keys the surface draws in no color, and what stands in for each.
16 recolor?: Readonly<Record<string, string>>
17}
18
19// Seconds into an animated drawing's play, or undefined for its still.
20export type Clock = (body: string, attrs: Attrs) => number | undefined
21
22// The kit a surface draws with: `Svg` only off the terminal, whose table may
23// answer a name it does not draw.
24export function kitFor(T: Kit, surface: string): Kit {
25 if (surface === 'terminal') return { Box: T.Box, Text: T.Text, Markdown: T.Markdown, isTerminal: true }
26
27 return surface === 'vscode' ? { ...T, recolor: VSCODE_RECOLOR } : T
28}
29
30// The VS Code panel draws `warning` in its menu's text color. Its `yellow`,
31// the terminal's ANSI yellow, follows the editor's theme as the key would.
32const VSCODE_RECOLOR = { warning: 'yellow' }
33
34const paint = (T: Kit, color: string | undefined) => (color && T.recolor?.[color]) ?? color
35
36// Off the terminal rows are set in a browser's monospace face, where an
37// arrowhead comes from a fallback font a fraction of a cell wide (so the
38// lines it meets step aside) and ░ draws as a solid block: one-cell ASCII
39// stands in for them.
40const OFF_TERMINAL: Record<string, string> = { '►': '>', '◄': '<', '▼': 'v', '░': '·' }
41const offTerminal = (run: Run): Run => ({ ...run, text: run.text.replace(/[►◄▼░]/g, ch => OFF_TERMINAL[ch]!) })
42
43// The longest source a surface's `Svg` takes; past it the cells are drawn.
44const SVG_SOURCE_MAX = 131072
45
46const CALLOUT: Record<string, { icon: string; color: string; label: string }> = {
47 note: { icon: '✎', color: 'suggestion', label: 'Note' },
48 info: { icon: '›', color: 'suggestion', label: 'Info' },
49 tip: { icon: '★', color: 'autoAccept', label: 'Tip' },
50 ok: { icon: '✓', color: 'success', label: 'Done' },
51 warn: { icon: '▲', color: 'warning', label: 'Warning' },
52 err: { icon: '✘', color: 'error', label: 'Error' },
53}
54const CALLOUT_ALIAS: Record<string, string> = { success: 'ok', warning: 'warn', error: 'err', danger: 'err', caution: 'warn', important: 'warn' }
55
56function textOf(T: Kit, run: Run): RenderNode {
57 const { Text } = T
58
59 return (
60 <Text
61 color={paint(T, run.fg)}
62 backgroundColor={paint(T, run.bg)}
63 bold={run.bold}
64 italic={run.italic}
65 underline={run.underline}
66 strikethrough={run.strike}
67 dimColor={run.dim}
68 inverse={run.inverse}
69 >
70 {run.text}
71 </Text>
72 )
73}
74
75function rowOf(T: Kit, row: Row): RenderNode {
76 const { Box, Text } = T
77 const runs = row.runs.length > 0 ? row.runs.map(r => textOf(T, r)) : ' '
78 if (!row.lead) {
79 return (
80 <Text mono wrap={row.wrap ? 'wrap' : 'truncate-end'}>
81 {runs}
82 </Text>
83 )
84 }
85
86 return (
87 <Box flexDirection="row">
88 <Box flexShrink={0}>
89 <Text mono>{row.lead.map(r => textOf(T, r))}</Text>
90 </Box>
91 <Box flexGrow={1} flexShrink={1}>
92 <Text mono wrap="wrap">
93 {runs}
94 </Text>
95 </Box>
96 </Box>
97 )
98}
99
100function rowsOf(T: Kit, rows: readonly Row[]): RenderNode {
101 const { Box } = T
102 const set = T.isTerminal ? rows : rows.map(r => ({ ...r, lead: r.lead?.map(offTerminal), runs: r.runs.map(offTerminal) }))
103
104 return <Box flexDirection="column">{set.map(r => rowOf(T, r))}</Box>
105}
106
107// Callouts and columns drawn inside one another at most this deep; deeper,
108// a body shows as its markdown. Each level is a few boxes for the surface
109// to lay out, so a reply nesting thousands would ask for a tree thousands
110// of boxes deep.
111const MAX_NESTING = 8
112
113export function drawBlocks(T: Kit, blocks: readonly Block[], avail: number, clock?: Clock, depth = 0): RenderNode[] {
114 return blocks.map(b => drawBlock(T, b, avail, clock, depth))
115}
116
117function drawBlock(T: Kit, b: Block, avail: number, clock?: Clock, depth = 0): RenderNode {
118 const { Box, Markdown, Text } = T
119 if (b.kind === 'md') return <Markdown text={b.text} />
120 if (b.kind === 'rich') return rowsOf(T, richRows(b.text, avail))
121 if ((b.name === 'callout' || b.name === 'col' || b.name === 'cols') && depth >= MAX_NESTING) return <Markdown text={b.body} />
122
123 if (b.name === 'callout' || b.name === 'col') {
124 const kind = CALLOUT_ALIAS[b.attrs.tone ?? ''] ?? b.attrs.tone ?? (b.name === 'col' ? '' : 'note')
125 const preset = CALLOUT[kind]
126 const color = preset?.color ?? tone(b.attrs.tone) ?? 'inactive'
127 const heading = b.attrs.title ?? (b.name === 'callout' ? preset?.label : undefined)
128
129 return (
130 <Box flexDirection="column" borderStyle="round" borderColor={paint(T, color)} paddingX={1} flexGrow={b.name === 'col' ? 1 : 0}>
131 {heading !== undefined && (
132 <Text bold color={paint(T, color)}>
133 {preset && b.name === 'callout' ? `${preset.icon} ` : ''}
134 {rich(heading, { bold: true, fg: color }).map(r => textOf(T, r))}
135 </Text>
136 )}
137 <Box flexDirection="column" gap={1}>
138 {drawBlocks(T, parseBlocks(b.body), avail - 4, clock, depth + 1)}
139 </Box>
140 </Box>
141 )
142 }
143
144 if (b.name === 'cols') {
145 const inner = parseBlocks(b.body)
146 const cols = inner.filter(x => x.kind === 'tag' && x.name === 'col')
147 const n = Math.max(1, cols.length)
148 const isSideBySide = avail >= n * 28
149 const each = isSideBySide ? Math.floor((avail - (n - 1)) / n) : avail
150
151 return (
152 <Box flexDirection={isSideBySide ? 'row' : 'column'} gap={1}>
153 {(cols.length > 0 ? cols : inner).map(c => (
154 <Box flexDirection="column" flexGrow={1} flexShrink={1} width={isSideBySide ? each : undefined}>
155 {drawBlock(T, c, each, clock, depth + 1)}
156 </Box>
157 ))}
158 </Box>
159 )
160 }
161
162 if (b.name === 'svg') return drawSvg(T, b, avail, clock)
163 // Off the terminal a row is taller than its box-drawing glyphs, so a drawn
164 // border breaks into dashes: the surface's own border is whole.
165 if (b.name === 'banner' && !T.isTerminal && b.isClosed) return drawBanner(T, b)
166
167 const widget = WIDGETS[b.name]
168 if (!widget) return <Markdown text={b.body} />
169 const rows = widget(b.body, b.attrs, avail)
170 if (!b.isClosed && rows.length === 0) return <Text dimColor>drawing {b.name}…</Text>
171
172 return rowsOf(T, rows)
173}
174
175function drawBanner(T: Kit, b: Extract<Block, { kind: 'tag' }>): RenderNode {
176 const { Box, Text } = T
177 const { letters, sub, border } = bannerParts(b.body, b.attrs)
178
179 return (
180 <Box flexDirection="column" alignSelf="flex-start" alignItems="center" borderStyle="round" borderColor={paint(T, border.fg)} paddingX={3}>
181 <Text mono>{letters.map(r => textOf(T, r))}</Text>
182 {sub !== undefined && (
183 <Text mono dimColor italic>
184 {sub}
185 </Text>
186 )}
187 </Box>
188 )
189}
190
191// Handed whole to a surface that draws SVG, where SMIL plays by itself;
192// in cells elsewhere, at the moment `clock` gives.
193function drawSvg(T: Kit, b: Extract<Block, { kind: 'tag' }>, avail: number, clock?: Clock): RenderNode {
194 const { Box, Text, Svg } = T
195 const native = Svg && b.isClosed ? nativeSvg(b.body, b.attrs) : undefined
196 if (Svg && native && native.source.length <= SVG_SOURCE_MAX) {
197 return (
198 <Box flexDirection="column">
199 {b.attrs.title !== undefined && <Text bold>{rich(b.attrs.title, { bold: true }).map(r => textOf(T, r))}</Text>}
200 <Svg source={native.source} alt={native.alt} width={native.width} isInteractive={native.isAnimated} />
201 </Box>
202 )
203 }
204 const rows = Svg && !b.isClosed ? [] : svg(b.body, b.attrs, avail, b.isClosed ? clock?.(b.body, b.attrs) : undefined)
205 if (!b.isClosed && rows.length === 0) return <Text dimColor>drawing svg…</Text>
206
207 return rowsOf(T, rows)
208}
209hooks/guide.ts 44 lines1// The tag guide, a section of the system prompt while decorated draws.
2
3export const LEVELS = ['low', 'medium', 'high'] as const
4export type Level = (typeof LEVELS)[number]
5
6// How much to decorate: the one part of the guide a level changes, so every
7// level teaches the same tags.
8const HOW_MUCH: Record<Level, string> = {
9 low: 'Keep decoration light: color a word or mark a status with a badge. Draw a block only when it is plainly the clearest form, such as a before → after `<bars>` or a `<tree>` someone asked for. Most replies need no tags.',
10 medium: 'Use one only where it shows more than prose or a table would: a status, numbers to compare, a pipeline, a call sequence, a tree. Most replies need none, or one or two.',
11 high: 'Whenever a reply has a shape, draw it as a block: steps for multi-step work, `<kv>` for a summary, a callout for the result, bars or a chart for numbers, a flow or tree for structure. A one-line answer still needs none.',
12}
13
14export const guideFor = (level: Level) => `# Decorated replies
15
16This session draws the tags below in your replies; Markdown works around them. ${HOW_MUCH[level]} Data in a tag must be real: never chart numbers you did not measure or read.
17
18A block tag opens at the start of a line and closes on its own line. Tags in code spans or fences show as written. A trailing \`{tone}\` colors a line inside a block. Tones: ok, warn, err, info, tip, accent, dim, or a hex color.
19
20Inline: \`<ok>\` \`<warn>\` \`<err>\` \`<info>\` \`<accent>\` \`<dim>\` color text, \`<hl>\` marks the one thing to notice, \`<kbd>Ctrl+O</kbd>\`, \`<badge tone="ok">PASS</badge>\`, \`<spark>3 5 2 8</spark>\`, \`<meter value="0.82"/>\`.
21
22Blocks take \`title="…"\`; their content is one item per line:
23- \`<callout tone="note|tip|ok|warn|err">\` any markdown, for the one point not to miss
24- \`<steps>\` \`[x]\` done, \`[~]\` doing, \`[ ]\` to do, \`[!]\` failed, \`[-]\` skipped; \` — detail\`
25- \`<tree>\` 2-space indent, \`dir/\`, \`+ \` \`- \` \`~ \` added/removed/changed, \` # note\`
26- \`<flow>\` \`a -> b -> c\`, \`a -> b, c\`, \`node {ok}\`
27- \`<seq>\` \`A -> B: call\`, \`B --> A: reply\`, \`== phase ==\`
28- \`<bars unit="ms" better="lower">\` \`label: 412\` or \`label: 412 -> 318\`
29- \`<chart unit="…" x="Mon,Tue">\` a line of numbers, or \`name: 1 2 3\` per series
30- \`<heat cols="M,T,W">\` \`row: 1 2 3\`
31- \`<timeline>\` \`when | what\`; \`<kv>\` \`key: value\`
32- \`<cols>\` two or three \`<col title="…">\` panels
33- \`<svg viewBox="0 0 120 60">\` only for a spatial shape (a layout, geometry); opening tag on one line, explicit fills, short \`<text>\`
34- \`<rule>Title</rule>\` a divider
35
36\`\`\`
37Tests are <ok>green</ok>; one <warn>flaky</warn> case remains.
38
39<bars title="Suite time" unit="s" better="lower">
40unit: 41 -> 12
41integration: 188 -> 140
42</bars>
43\`\`\``
44hooks/parse.ts 572 lines1// Splits a reply into plain markdown and decorated parts, and turns inline
2// markup into styled runs. Only the tag names below are markup: anything
3// else in angle brackets (`Array<string>`, `<div>`) stays text, and nothing
4// inside a code span or a fence is ever read as a tag.
5
6import { bar, clip, hexOf, mix, nums, pad, runsWidth, spark, tone, type Row, type Run, type Style, width } from './text'
7
8export const BLOCK_TAGS = new Set([
9 'callout',
10 'cols',
11 'col',
12 'bars',
13 'chart',
14 'heat',
15 'flow',
16 'seq',
17 'tree',
18 'steps',
19 'timeline',
20 'kv',
21 'rule',
22 'banner',
23 'svg',
24])
25
26export const INLINE_TAGS = new Set([
27 'ok',
28 'warn',
29 'err',
30 'info',
31 'dim',
32 'accent',
33 'hl',
34 'c',
35 'kbd',
36 'badge',
37 'grad',
38 'spark',
39 'meter',
40])
41
42export type Attrs = Record<string, string>
43
44export type Block =
45 | { kind: 'md'; text: string }
46 | { kind: 'rich'; text: string }
47 | { kind: 'tag'; name: string; attrs: Attrs; body: string; isClosed: boolean }
48
49const ATTR = /([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>/]+)))?/g
50
51export function parseAttrs(s: string): Attrs {
52 const attrs: Attrs = {}
53 for (const m of s.matchAll(ATTR)) attrs[(m[1] ?? '').toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? ''
54
55 return attrs
56}
57
58const FENCE = /^\s*(`{3,}|~{3,})/
59
60// Code spans and fences blanked to spaces, so tag searches skip them, and
61// the offsets of the lines that start outside a fence.
62function masking(text: string): { mask: string; clean: Set<number> } {
63 let inFence: string | undefined
64 const clean = new Set<number>()
65 let at = 0
66 const lines = text.split('\n').map(line => {
67 if (!inFence) clean.add(at)
68 at += line.length + 1
69 const fence = FENCE.exec(line)?.[1]
70 if (inFence) {
71 if (fence && fence[0] === inFence[0] && fence.length >= inFence.length) inFence = undefined
72
73 return ' '.repeat(line.length)
74 }
75 if (fence) {
76 inFence = fence
77
78 return ' '.repeat(line.length)
79 }
80
81 return line.replace(/(`+)[^`]*?\1/g, m => ' '.repeat(m.length))
82 })
83
84 return { mask: lines.join('\n'), clean }
85}
86
87function masked(text: string): string {
88 return masking(text).mask
89}
90
91const INLINE_RE = new RegExp(`</?(?:${[...INLINE_TAGS].join('|')})(?=[\\s>/])`)
92
93export function hasInline(text: string): boolean {
94 return INLINE_RE.test(masked(text))
95}
96
97export function hasMarkup(text: string): boolean {
98 const m = masked(text)
99
100 return INLINE_RE.test(m) || new RegExp(`(^|\\n)\\s*<(?:${[...BLOCK_TAGS].join('|')})(?=[\\s>/])`).test(m)
101}
102
103// A tag's insides up to its `>`: a quoted value may hold `>` or `/>` (a title
104// with inline markup), and an unterminated quote falls back to a plain char.
105// Each piece is taken whole (a lookahead and its backreference), so a match
106// that fails never retries a quoted value as plain chars: hostile quotes
107// cost linear time, not exponential.
108const INSIDE = String.raw`(?:(?=(?<piece>"[^"]*"|'[^']*'|[^>]))\k<piece>)`
109// Groups: name, attributes, INSIDE's piece, self-close, the text after `>`.
110const OPEN_LINE = new RegExp(String.raw`^\s*<([a-z]+)((?:\s+${INSIDE}*?)?)\s*(\/?)>(.*)$`)
111// The most pieces a tag inside a body may hold before its `>`, so a body
112// full of unfinished tags is searched in linear time.
113const TAG_PIECES = 1024
114
115// Where `</name>` closes the tag opened before `from`, counting nested ones
116// of the same name, in text whose code spans and fences are masked.
117function findClose(m: string, name: string, from: number): { at: number; end: number } | undefined {
118 const re = new RegExp(`<(/?)${name}(?=[\\s>/])(${INSIDE}{0,${TAG_PIECES}}?)>`, 'g')
119 re.lastIndex = from
120 let depth = 1
121 for (let hit = re.exec(m); hit; hit = re.exec(m)) {
122 if (hit[1]) depth -= 1
123 else if (!(hit[2] ?? '').trimEnd().endsWith('/')) depth += 1
124 if (depth === 0) return { at: hit.index, end: hit.index + hit[0].length }
125 }
126
127 return undefined
128}
129
130export function parseBlocks(text: string): Block[] {
131 const blocks: Block[] = []
132 let md: string[] = []
133 const flush = () => {
134 if (md.length > 0) blocks.push(...splitRich(md.join('\n')))
135 md = []
136 }
137
138 // The reply is masked once, and each block's close searched in it from the
139 // block's start, so a reply of many blocks parses in one pass. A body that
140 // starts where that mask would differ from the body's own is masked apart.
141 const { mask, clean } = masking(text)
142 let at = 0
143 let inFence: string | undefined
144 while (at < text.length) {
145 const nl = text.indexOf('\n', at)
146 const end = nl === -1 ? text.length : nl
147 const next = nl === -1 ? text.length : nl + 1
148 const line = text.slice(at, end)
149 const fence = FENCE.exec(line)?.[1]
150 if (inFence) {
151 if (fence && fence[0] === inFence[0] && fence.length >= inFence.length) inFence = undefined
152 md.push(line)
153 at = next
154 continue
155 }
156 if (fence) {
157 inFence = fence
158 md.push(line)
159 at = next
160 continue
161 }
162
163 const open = OPEN_LINE.exec(line)
164 if (!open || !BLOCK_TAGS.has(open[1] ?? '')) {
165 md.push(line)
166 at = next
167 continue
168 }
169
170 flush()
171 const [, name = '', rawAttrs = '', , selfClose, tail = ''] = open
172 const attrs = parseAttrs(rawAttrs)
173 // The body starts right after `>`: on the same line when the tag holds
174 // text there, else on the next line.
175 const from = tail.trim() ? end - tail.length : next
176 if (selfClose) {
177 blocks.push({ kind: 'tag', name, attrs, body: '', isClosed: true })
178 at = from
179 continue
180 }
181
182 const isMasked = from === next ? clean.has(next) : clean.has(at) && !/[`~]/.test(line)
183 const close = isMasked ? findClose(mask, name, from) : shift(findClose(masked(text.slice(from)), name, 0), from)
184 if (!close) {
185 // Unclosed: a reply still streaming, or a forgotten close. Draw what is there.
186 blocks.push({ kind: 'tag', name, attrs, body: trimBody(text.slice(from)), isClosed: false })
187 break
188 }
189 blocks.push({ kind: 'tag', name, attrs, body: trimBody(text.slice(from, close.at)), isClosed: true })
190 const left = text.slice(close.end)
191 at = left.startsWith('\n') ? close.end + 1 : left.trim() ? close.end : close.end + /^[^\n]*\n?/.exec(left)![0].length
192 }
193 flush()
194
195 return blocks
196}
197
198function shift(span: { at: number; end: number } | undefined, by: number): { at: number; end: number } | undefined {
199 return span && { at: span.at + by, end: span.end + by }
200}
201
202function trimBody(s: string): string {
203 return s.replace(/^\s*\n/, '').replace(/\n\s*$/, '')
204}
205
206// Plain markdown stays one block for the engine's own renderer; only the
207// paragraphs holding inline markup are drawn by hand.
208function splitRich(text: string): Block[] {
209 if (!hasInline(text)) return text.trim() ? [{ kind: 'md', text }] : []
210
211 const groups: string[][] = [[]]
212 let inFence: string | undefined
213 for (const line of text.split('\n')) {
214 const fence = FENCE.exec(line)?.[1]
215 if (inFence) {
216 if (fence && fence[0] === inFence[0] && fence.length >= inFence.length) inFence = undefined
217 } else if (fence) {
218 inFence = fence
219 } else if (!line.trim()) {
220 groups.push([])
221 continue
222 }
223 groups[groups.length - 1]?.push(line)
224 }
225
226 const out: Block[] = []
227 for (const group of groups.filter(g => g.length > 0)) {
228 const chunk = group.join('\n')
229 const kind = hasInline(chunk) ? 'rich' : 'md'
230 const last = out[out.length - 1]
231 if (kind === 'md' && last?.kind === 'md') last.text += '\n\n' + chunk
232 else out.push({ kind, text: chunk })
233 }
234
235 return out
236}
237
238// ── inline ──────────────────────────────────────────────────────────────
239
240export type Inline =
241 | { t: 'text'; s: string }
242 | { t: 'code'; s: string }
243 | { t: 'tag'; name: string; attrs: Attrs; kids: Inline[] }
244
245const TAG_AT = /^<(\/?)([a-z]+)((?:\s+[\w-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>/]+))?)*)\s*(\/?)>/
246
247export function parseInline(s: string): Inline[] {
248 const root: Inline[] = []
249 const stack: Array<{ name: string; kids: Inline[] }> = [{ name: '', kids: root }]
250 let text = ''
251 const top = () => stack[stack.length - 1]?.kids ?? root
252 const flush = () => {
253 if (text) top().push({ t: 'text', s: text })
254 text = ''
255 }
256
257 for (let i = 0; i < s.length; ) {
258 const ch = s[i]
259 if (ch === '`') {
260 const run = /^`+/.exec(s.slice(i))![0]
261 const end = s.indexOf(run, i + run.length)
262 if (end !== -1) {
263 flush()
264 top().push({ t: 'code', s: s.slice(i + run.length, end).replace(/^ (.*) $/, '$1') })
265 i = end + run.length
266 continue
267 }
268 }
269 if (ch === '\\' && s[i + 1] === '<') {
270 text += '<'
271 i += 2
272 continue
273 }
274 if (ch === '<') {
275 const m = TAG_AT.exec(s.slice(i))
276 if (m && INLINE_TAGS.has(m[2] ?? '')) {
277 const [whole, closing, name = '', rawAttrs = '', selfClose] = m
278 flush()
279 if (closing) {
280 const at = stack.map(f => f.name).lastIndexOf(name)
281 if (at > 0) stack.length = at // closes it and anything left open inside
282 } else {
283 const kids: Inline[] = []
284 top().push({ t: 'tag', name, attrs: parseAttrs(rawAttrs), kids })
285 if (!selfClose) stack.push({ name, kids })
286 }
287 i += whole.length
288 continue
289 }
290 }
291 text += ch
292 i += 1
293 }
294 flush()
295
296 return root
297}
298
299function plain(nodes: readonly Inline[]): string {
300 return nodes.map(n => (n.t === 'tag' ? plain(n.kids) : n.s)).join('')
301}
302
303// A span holds at most 500 characters and a link's target 2048: unbounded,
304// each opener with no closer scans to the end of the line, and a long line
305// of them costs quadratic time.
306const EMPHASIS =
307 /(\*\*|__)(?=\S)(.{1,500}?)(?<=\S)\1|(?<![\w*])\*(?=\S)(.{1,500}?)(?<=\S)\*(?!\*)|(?<![\w_])_(?=\S)(.{1,500}?)(?<=\S)_(?![\w_])|~~(.{1,500}?)~~|\[([^\]]{1,500})\]\(([^)\s]{1,2048})\)/dg
308
309const BOLD = 1
310const ITALIC = 2
311const STRIKE = 4
312const LINK = 8
313const FLAG_OF: Record<number, number> = { 2: BOLD, 3: ITALIC, 4: ITALIC, 5: STRIKE, 6: LINK }
314
315// A styled piece; `isRaw` ones (code, keys, badges, gauges) hold no markdown.
316type Piece = Run & { isRaw?: boolean }
317
318// Markdown's inline emphasis and links over a whole line of pieces, so
319// `**bold <ok>tag</ok>**` spans the tag: the delimiters are found in the
320// joined text, cut out, and their styles laid over what they enclose.
321function emphasis(pieces: readonly Piece[]): Run[] {
322 const scan = pieces.map(p => (p.isRaw ? ''.repeat(p.text.length) : p.text)).join('')
323 const cut = new Uint8Array(scan.length)
324 const flags = new Uint8Array(scan.length)
325 const walk = (from: number, to: number) => {
326 for (const m of scan.slice(from, to).matchAll(EMPHASIS)) {
327 const group = [2, 3, 4, 5, 6].find(k => m[k] !== undefined)!
328 const [s, e] = m.indices![group]!
329 cut.fill(1, from + m.index, from + s)
330 cut.fill(1, from + e, from + m.index + m[0].length)
331 for (let i = from + s; i < from + e; i++) flags[i]! |= FLAG_OF[group]!
332 walk(from + s, from + e)
333 }
334 }
335 walk(0, scan.length)
336
337 const runs: Run[] = []
338 let at = 0
339 for (const { isRaw: _, ...p } of pieces) {
340 const end = at + p.text.length
341 for (let k = at; k < end; ) {
342 let j = k + 1
343 while (j < end && cut[j] === cut[k] && flags[j] === flags[k]) j++
344 if (!cut[k]) runs.push({ ...p, ...flagStyle(flags[k]!, p), text: p.text.slice(k - at, j - at) })
345 k = j
346 }
347 at = end
348 }
349
350 return runs
351}
352
353function flagStyle(f: number, p: Style): Style {
354 return {
355 ...(f & BOLD ? { bold: true } : {}),
356 ...(f & ITALIC ? { italic: true } : {}),
357 ...(f & STRIKE ? { strike: true } : {}),
358 ...(f & LINK ? { underline: true, fg: p.fg ?? 'suggestion' } : {}),
359 }
360}
361
362const TAG_TONE: Record<string, string> = {
363 ok: 'success',
364 warn: 'warning',
365 err: 'error',
366 info: 'suggestion',
367 accent: 'claude',
368}
369
370export function inlineRuns(nodes: readonly Inline[], base: Style = {}): Run[] {
371 return emphasis(pieces(nodes, base))
372}
373
374function pieces(nodes: readonly Inline[], base: Style): Piece[] {
375 const out: Piece[] = []
376 for (const node of nodes) {
377 if (node.t === 'text') out.push({ ...base, text: node.s })
378 else if (node.t === 'code') out.push({ ...base, fg: 'permission', text: node.s, isRaw: true })
379 else out.push(...tagRuns(node, base))
380 }
381
382 return out
383}
384
385function tagRuns(node: Extract<Inline, { t: 'tag' }>, base: Style): Piece[] {
386 const { name, attrs, kids } = node
387 const tag = tone(attrs.tone)
388 const raw = (runs: Run[]): Piece[] => runs.map(r => ({ ...r, isRaw: true }))
389 switch (name) {
390 case 'ok':
391 case 'warn':
392 case 'err':
393 case 'info':
394 case 'accent':
395 return pieces(kids, { ...base, fg: TAG_TONE[name], bold: base.bold || 'bold' in attrs })
396 case 'dim':
397 return pieces(kids, { ...base, fg: 'inactive' })
398 case 'hl':
399 return pieces(kids, { ...base, fg: tag ?? 'warning', inverse: true })
400 case 'c':
401 return pieces(kids, {
402 ...base,
403 fg: tone(attrs.fg ?? attrs.color ?? attrs.tone) ?? base.fg,
404 bg: tone(attrs.bg) ?? base.bg,
405 bold: base.bold || 'bold' in attrs,
406 italic: base.italic || 'italic' in attrs,
407 underline: base.underline || 'underline' in attrs,
408 })
409 case 'kbd':
410 return raw([{ ...base, inverse: true, bold: true, text: ` ${plain(kids).trim()} ` }])
411 case 'badge':
412 return raw([{ ...base, fg: tag ?? 'claude', inverse: true, bold: true, text: ` ${plain(kids).trim()} ` }])
413 case 'grad':
414 return raw(gradient(plain(kids), attrs, base))
415 case 'spark': {
416 const values = nums(attrs.values ?? plain(kids))
417
418 return raw([{ ...base, fg: tag ?? base.fg ?? 'claude', text: spark(values) }])
419 }
420 case 'meter': {
421 const value = Number(attrs.value ?? (plain(kids) || 0))
422 const max = Number(attrs.max ?? (value > 1 ? 100 : 1))
423 const cells = Math.max(3, Math.min(40, Number(attrs.width ?? 10)))
424 const frac = max > 0 ? value / max : 0
425 const fill = bar(frac, cells)
426 const color = tag ?? (frac >= 0.8 ? 'success' : frac >= 0.5 ? 'warning' : 'error')
427 const label = attrs.label ?? `${Math.round(frac * 100)}%`
428
429 return raw([
430 { ...base, fg: color, text: fill },
431 { ...base, fg: 'inactive', text: '·'.repeat(Math.max(0, cells - width(fill))) },
432 ...(label === 'none' ? [] : [{ ...base, text: ' ' + label }]),
433 ])
434 }
435 default:
436 return pieces(kids, base)
437 }
438}
439
440function gradient(s: string, attrs: Attrs, base: Style): Run[] {
441 const from = hexOf(attrs.from, '#d77757')
442 const to = hexOf(attrs.to, '#af87ff')
443 const chars = [...s]
444
445 return chars.map((ch, i) => ({
446 ...base,
447 bold: base.bold || 'bold' in attrs,
448 fg: mix(from, to, chars.length > 1 ? i / (chars.length - 1) : 0),
449 text: ch,
450 }))
451}
452
453// Inline markup and markdown emphasis in one line of text.
454export function rich(s: string, base: Style = {}): Run[] {
455 return inlineRuns(parseInline(s), base)
456}
457
458// A paragraph holding inline markup, drawn by hand: list items, headings,
459// quotes and tables keep their shape; soft line breaks join as markdown joins them.
460export function richRows(text: string, avail = 80): Row[] {
461 const rows: Row[] = []
462 let open: { lead?: Run[]; text: string; base: Style } | undefined
463 const close = () => {
464 if (open) rows.push({ lead: open.lead, runs: rich(open.text, open.base), wrap: true })
465 open = undefined
466 }
467 const all = text.split('\n')
468 for (let i = 0; i < all.length; i++) {
469 const line = all[i]!
470 if (line.includes('|') && isTableRule(all[i + 1])) {
471 close()
472 const rule = all[++i]!
473 const body: string[] = []
474 while (i + 1 < all.length && all[i + 1]!.includes('|')) body.push(all[++i]!)
475 rows.push(...table(line, rule, body, avail))
476 continue
477 }
478 const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line) as [string, string, string, string] | null
479 const heading = /^\s*(#{1,6})\s+(.*)$/.exec(line) as [string, string, string] | null
480 const quote = /^\s*>\s?(.*)$/.exec(line) as [string, string] | null
481 if (item) {
482 close()
483 const bullet = /\d/.test(item[2]) ? item[2] : '•'
484 open = { lead: [{ text: item[1] + bullet + ' ' }], text: item[3], base: {} }
485 } else if (heading) {
486 close()
487 open = { text: heading[2], base: { bold: true, underline: heading[1].length === 1 } }
488 close()
489 } else if (quote) {
490 close()
491 open = { lead: [{ fg: 'inactive', text: '▎ ' }], text: quote[1], base: { italic: true } }
492 } else if (open) open.text += ' ' + line.trim()
493 else open = { text: line.trim(), base: {} }
494 }
495 close()
496
497 return rows
498}
499
500// ── tables ──────────────────────────────────────────────────────────────
501
502function isTableRule(line: string | undefined): boolean {
503 return line !== undefined && /^[\s|:-]+$/.test(line) && line.includes('-') && line.includes('|')
504}
505
506// A table row's cells: `|` splits outside code spans, `\|` stays a pipe.
507function cells(line: string): string[] {
508 const out: string[] = []
509 let cur = ''
510 let tick = ''
511 for (let i = 0; i < line.length; i++) {
512 const ch = line[i]!
513 if (ch === '`') {
514 const run = /^`+/.exec(line.slice(i))![0]
515 if (!tick) tick = run
516 else if (run === tick) tick = ''
517 cur += run
518 i += run.length - 1
519 } else if (ch === '\\' && line[i + 1] === '|') {
520 cur += '\\|'
521 i += 1
522 } else if (ch === '|' && !tick) {
523 out.push(cur)
524 cur = ''
525 } else cur += ch
526 }
527 out.push(cur)
528 const trimmed = out.map(c => c.trim().replace(/\\\|/g, '|'))
529 if (line.trim().startsWith('|') && trimmed[0] === '') trimmed.shift()
530 if (line.trim().endsWith('|') && trimmed[trimmed.length - 1] === '') trimmed.pop()
531
532 return trimmed
533}
534
535// A markdown table whose cells hold inline markup, drawn boxed; too wide for
536// the room, each row becomes a block of `header value` lines.
537function table(head: string, rule: string, body: string[], avail: number): Row[] {
538 const header = cells(head)
539 const n = header.length
540 const aligns = cells(rule).map(c => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'))
541 const grid = [header, ...body.map(cells)].map(r => Array.from({ length: n }, (_, k) => r[k] ?? ''))
542 const runs = grid.map((r, ri) => r.map(c => rich(c, ri === 0 ? { bold: true } : {})))
543 const widths = header.map((_, k) => Math.max(3, ...runs.map(r => runsWidth(r[k]!))))
544 const dim: Style = { fg: 'inactive' }
545
546 if (widths.reduce((s, w) => s + w, 0) + 3 * n + 1 > avail) {
547 const keyW = Math.min(24, Math.max(...header.map(h => width(h))))
548 const out: Row[] = []
549 runs.slice(1).forEach((r, ri) => {
550 if (ri > 0) out.push({ runs: [] })
551 r.forEach((c, k) => out.push({ lead: [{ ...dim, text: pad(clip(plain(parseInline(header[k]!)), keyW), keyW) + ' ' }], runs: c, wrap: true }))
552 })
553
554 return out
555 }
556
557 const line = (l: string, m: string, r: string): Row => ({ runs: [{ ...dim, text: l + widths.map(w => '─'.repeat(w + 2)).join(m) + r }] })
558 const row = (r: Run[][]): Row => {
559 const out: Run[] = [{ ...dim, text: '│' }]
560 r.forEach((c, k) => {
561 const gap = widths[k]! - runsWidth(c)
562 const a = aligns[k] ?? 'left'
563 const left = a === 'right' ? gap : a === 'center' ? Math.floor(gap / 2) : 0
564 out.push({ text: ' '.repeat(left + 1) }, ...c, { text: ' '.repeat(gap - left + 1) }, { ...dim, text: '│' })
565 })
566
567 return { runs: out }
568 }
569
570 return [line('┌', '┬', '┐'), row(runs[0]!), line('├', '┼', '┤'), ...runs.slice(1).map(row), line('└', '┴', '┘')]
571}
572hooks/svg.ts 1403 lines1// Inline SVG rasterised into terminal cells. Half blocks (the default) give
2// each cell two full-color pixels, near square on a 1:2 cell; braille gives
3// 2×4 dots in one color, finer for line art. `<text>` is set in real glyphs
4// on the nearest cell, so labels stay crisp however small the drawing is.
5//
6// Shapes: rect (rounded), circle, ellipse, line, polyline, polygon, path
7// (every command, arcs included), g, use, text; solid, linear and radial
8// paint; transform, opacity, fill-rule, stroke-linecap. No filters, masks,
9// clipping, patterns or dashes.
10
11import type { Attrs } from './parse'
12import { HEX_OF, rgbToHex, type Row, type Run, tone, width } from './text'
13
14type El = { name: string; attrs: Attrs; kids: El[]; text: string }
15type Pt = [number, number]
16type Poly = { pts: Pt[]; isClosed: boolean }
17type M = [number, number, number, number, number, number]
18type Rgba = [number, number, number, number]
19
20// ── a forgiving XML reader, enough for the SVG a reply holds ────────────
21
22const TOKEN =
23 /<!--[\s\S]*?(?:-->|$)|<[?!][^>]*>|<(\/?)([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>/]+))?)*)\s*(\/?)>|([^<]+|<)/g
24const XATTR = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>/]+)))?/g
25const ENTITY: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
26
27function decode(s: string): string {
28 return s.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (m, e: string) => {
29 if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)))
30
31 return ENTITY[e] ?? m
32 })
33}
34
35// Case kept, unlike block attributes: SVG spells `viewBox`, `fill-rule`.
36function xmlAttrs(s: string): Attrs {
37 const out: Attrs = {}
38 for (const m of s.matchAll(XATTR)) out[m[1]!] = decode(m[2] ?? m[3] ?? m[4] ?? '')
39
40 return out
41}
42
43// Past these the markup is left unread, as if it had not streamed in yet:
44// elements nested deeper than the walk draws, and more of them than any
45// drawing in a reply needs.
46const XML_MAX_DEPTH = 48
47const XML_MAX_ELEMENTS = 4000
48
49export function parseXml(src: string): El[] {
50 const root: El = { name: '', attrs: {}, kids: [], text: '' }
51 const stack = [root]
52 let count = 0
53 // Elements opened past XML_MAX_DEPTH, unread along with what they hold.
54 let unread = 0
55 for (const m of src.matchAll(TOKEN)) {
56 const top = stack[stack.length - 1]!
57 if (m[5] !== undefined) {
58 if (!unread) top.text += decode(m[5])
59 continue
60 }
61 if (!m[2]) continue
62 if (m[1]) {
63 if (unread) {
64 unread -= 1
65 continue
66 }
67 const at = stack.map(e => e.name).lastIndexOf(m[2])
68 if (at > 0) stack.length = at
69 continue
70 }
71 if (unread || stack.length > XML_MAX_DEPTH) {
72 if (!m[4]) unread += 1
73 continue
74 }
75 if (++count > XML_MAX_ELEMENTS) break
76 const el: El = { name: m[2], attrs: xmlAttrs(m[3] ?? ''), kids: [], text: '' }
77 top.kids.push(el)
78 if (!m[4]) stack.push(el)
79 }
80
81 return root.kids
82}
83
84// ── numbers, lengths, transforms ────────────────────────────────────────
85
86const NUM = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g
87
88function numList(s: string | undefined): number[] {
89 return (s?.match(NUM) ?? []).map(Number)
90}
91
92const ID: M = [1, 0, 0, 1, 0, 0]
93
94// `m` after `n`: a point goes through `n` first.
95function mul(m: M, n: M): M {
96 return [
97 m[0] * n[0] + m[2] * n[1],
98 m[1] * n[0] + m[3] * n[1],
99 m[0] * n[2] + m[2] * n[3],
100 m[1] * n[2] + m[3] * n[3],
101 m[0] * n[4] + m[2] * n[5] + m[4],
102 m[1] * n[4] + m[3] * n[5] + m[5],
103 ]
104}
105
106function apply(m: M, [x, y]: Pt): Pt {
107 return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
108}
109
110function parseTransform(s: string | undefined): M {
111 let m = ID
112 for (const t of s?.matchAll(/(\w+)\s*\(([^)]*)\)/g) ?? []) {
113 const v = numList(t[2])
114 const [a = 0, b, c] = v
115 const rad = (a * Math.PI) / 180
116 let n: M
117 switch (t[1]) {
118 case 'matrix':
119 n = v.length >= 6 ? (v.slice(0, 6) as M) : ID
120 break
121 case 'translate':
122 n = [1, 0, 0, 1, a, b ?? 0]
123 break
124 case 'scale':
125 n = [a, 0, 0, b ?? a, 0, 0]
126 break
127 case 'rotate':
128 n = [Math.cos(rad), Math.sin(rad), -Math.sin(rad), Math.cos(rad), 0, 0]
129 if (b !== undefined) n = mul(mul([1, 0, 0, 1, b, c ?? 0], n), [1, 0, 0, 1, -b, -(c ?? 0)])
130 break
131 case 'skewX':
132 n = [1, 0, Math.tan(rad), 1, 0, 0]
133 break
134 case 'skewY':
135 n = [1, Math.tan(rad), 0, 1, 0, 0]
136 break
137 default:
138 continue
139 }
140 m = mul(m, n)
141 }
142
143 return m
144}
145
146// ── geometry: every shape becomes polylines in user space ───────────────
147
148type Segs = (len: number) => number
149
150function ellipsePts(cx: number, cy: number, rx: number, ry: number, segs: Segs, from = 0, to = Math.PI * 2): Pt[] {
151 const n = segs(Math.abs(to - from) * Math.max(rx, ry))
152 const out: Pt[] = []
153 for (let k = 0; k <= n; k++) {
154 const t = from + ((to - from) * k) / n
155 out.push([cx + rx * Math.cos(t), cy + ry * Math.sin(t)])
156 }
157
158 return out
159}
160
161function arcPts(x1: number, y1: number, rx: number, ry: number, phi: number, isLarge: boolean, isSweep: boolean, x2: number, y2: number, segs: Segs): Pt[] {
162 if (x1 === x2 && y1 === y2) return []
163 if (rx === 0 || ry === 0) return [[x2, y2]]
164 rx = Math.abs(rx)
165 ry = Math.abs(ry)
166 const r = (phi * Math.PI) / 180
167 const co = Math.cos(r)
168 const si = Math.sin(r)
169 const dx = (x1 - x2) / 2
170 const dy = (y1 - y2) / 2
171 const xp = co * dx + si * dy
172 const yp = -si * dx + co * dy
173 const lam = (xp * xp) / (rx * rx) + (yp * yp) / (ry * ry)
174 if (lam > 1) {
175 rx *= Math.sqrt(lam)
176 ry *= Math.sqrt(lam)
177 }
178 const num = rx * rx * ry * ry - rx * rx * yp * yp - ry * ry * xp * xp
179 const den = rx * rx * yp * yp + ry * ry * xp * xp
180 const k = (isLarge === isSweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den))
181 const cxp = (k * rx * yp) / ry
182 const cyp = (-k * ry * xp) / rx
183 const cx = co * cxp - si * cyp + (x1 + x2) / 2
184 const cy = si * cxp + co * cyp + (y1 + y2) / 2
185 const angle = (ux: number, uy: number, vx: number, vy: number) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
186 const t1 = angle(1, 0, (xp - cxp) / rx, (yp - cyp) / ry)
187 let dt = angle((xp - cxp) / rx, (yp - cyp) / ry, (-xp - cxp) / rx, (-yp - cyp) / ry)
188 if (!isSweep && dt > 0) dt -= Math.PI * 2
189 if (isSweep && dt < 0) dt += Math.PI * 2
190 const n = segs(Math.abs(dt) * Math.max(rx, ry))
191 const out: Pt[] = []
192 for (let j = 1; j <= n; j++) {
193 const t = t1 + (dt * j) / n
194 out.push([cx + co * rx * Math.cos(t) - si * ry * Math.sin(t), cy + si * rx * Math.cos(t) + co * ry * Math.sin(t)])
195 }
196
197 return out
198}
199
200// Stops at the first thing it cannot read, so a path still streaming draws
201// as far as it has come.
202export function pathPolys(d: string, segs: Segs): Poly[] {
203 const polys: Poly[] = []
204 let cur: Pt[] = []
205 let x = 0
206 let y = 0
207 let sx = 0
208 let sy = 0
209 let ctrl: Pt = [0, 0]
210 let prev = ''
211 let cmd = ''
212 let i = 0
213 const skip = () => {
214 while (i < d.length && /[\s,]/.test(d[i]!)) i++
215 }
216 const n = (): number | undefined => {
217 skip()
218 const re = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/y
219 re.lastIndex = i
220 const m = re.exec(d)
221 if (!m) return undefined
222 i += m[0].length
223
224 return Number(m[0])
225 }
226 // Arc flags may run together: `a1 1 0 011 1`.
227 const flag = (): boolean | undefined => {
228 skip()
229 const c = d[i]
230 if (c !== '0' && c !== '1') return undefined
231 i += 1
232
233 return c === '1'
234 }
235 const nums = (k: number): number[] | undefined => {
236 const out: number[] = []
237 for (let j = 0; j < k; j++) {
238 const v = n()
239 if (v === undefined) return undefined
240 out.push(v)
241 }
242
243 return out
244 }
245 const flush = (isClosed: boolean) => {
246 if (cur.length > 1) polys.push({ pts: cur, isClosed })
247 cur = []
248 }
249 const to = (...pts: Pt[]) => {
250 if (cur.length === 0) cur.push([x, y])
251 cur.push(...pts)
252 }
253 const cubic = (p0: Pt, p1: Pt, p2: Pt, p3: Pt) => {
254 const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) + Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) + Math.hypot(p3[0] - p2[0], p3[1] - p2[1])
255 const k = segs(len)
256 for (let j = 1; j <= k; j++) {
257 const t = j / k
258 const u = 1 - t
259 to([
260 u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
261 u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
262 ])
263 }
264 }
265 const quad = (p0: Pt, p1: Pt, p2: Pt) => cubic(p0, [p0[0] + (2 / 3) * (p1[0] - p0[0]), p0[1] + (2 / 3) * (p1[1] - p0[1])], [p2[0] + (2 / 3) * (p1[0] - p2[0]), p2[1] + (2 / 3) * (p1[1] - p2[1])], p2)
266
267 for (;;) {
268 skip()
269 if (i >= d.length) break
270 if (/[a-zA-Z]/.test(d[i]!)) cmd = d[i++]!
271 else if (!cmd) break
272 const isRel = cmd >= 'a'
273 const C = cmd.toUpperCase()
274 const ox = isRel ? x : 0
275 const oy = isRel ? y : 0
276 if (C === 'Z') {
277 if (cur.length > 0) flush(true)
278 x = sx
279 y = sy
280 prev = 'Z'
281 cmd = ''
282 continue
283 }
284 const at = i
285 let ok = true
286 if (C === 'M') {
287 const v = nums(2)
288 if (v) {
289 flush(false)
290 x = ox + v[0]!
291 y = oy + v[1]!
292 sx = x
293 sy = y
294 cur = [[x, y]]
295 cmd = isRel ? 'l' : 'L'
296 } else ok = false
297 } else if (C === 'L') {
298 const v = nums(2)
299 if (v) {
300 to([ox + v[0]!, oy + v[1]!])
301 ;[x, y] = [ox + v[0]!, oy + v[1]!]
302 } else ok = false
303 } else if (C === 'H' || C === 'V') {
304 const v = n()
305 if (v !== undefined) {
306 if (C === 'H') x = ox + v
307 else y = oy + v
308 to([x, y])
309 } else ok = false
310 } else if (C === 'C' || C === 'S') {
311 const v = nums(C === 'C' ? 6 : 4)
312 if (v) {
313 const p1: Pt = C === 'C' ? [ox + v[0]!, oy + v[1]!] : prev === 'C' || prev === 'S' ? [2 * x - ctrl[0], 2 * y - ctrl[1]] : [x, y]
314 const rest = C === 'C' ? v.slice(2) : v
315 const p2: Pt = [ox + rest[0]!, oy + rest[1]!]
316 const p3: Pt = [ox + rest[2]!, oy + rest[3]!]
317 cubic([x, y], p1, p2, p3)
318 ctrl = p2
319 ;[x, y] = p3
320 } else ok = false
321 } else if (C === 'Q' || C === 'T') {
322 const v = nums(C === 'Q' ? 4 : 2)
323 if (v) {
324 const p1: Pt = C === 'Q' ? [ox + v[0]!, oy + v[1]!] : prev === 'Q' || prev === 'T' ? [2 * x - ctrl[0], 2 * y - ctrl[1]] : [x, y]
325 const rest = C === 'Q' ? v.slice(2) : v
326 const p2: Pt = [ox + rest[0]!, oy + rest[1]!]
327 quad([x, y], p1, p2)
328 ctrl = p1
329 ;[x, y] = p2
330 } else ok = false
331 } else if (C === 'A') {
332 const v = nums(3)
333 const large = v && flag()
334 const sweep = large !== undefined ? flag() : undefined
335 const end = sweep !== undefined ? nums(2) : undefined
336 if (v && large !== undefined && sweep !== undefined && end) {
337 const ex = ox + end[0]!
338 const ey = oy + end[1]!
339 const pts = arcPts(x, y, v[0]!, v[1]!, v[2]!, large, sweep, ex, ey, segs)
340 if (pts.length) to(...pts)
341 ;[x, y] = [ex, ey]
342 } else ok = false
343 } else ok = false
344 if (!ok) {
345 i = at
346 break
347 }
348 prev = C
349 }
350 flush(false)
351
352 return polys
353}
354
355// ── paint ───────────────────────────────────────────────────────────────
356
357const CSS: Record<string, string> = {
358 black: '000000', white: 'ffffff', red: 'ff0000', green: '008000', blue: '0000ff', yellow: 'ffff00',
359 cyan: '00ffff', aqua: '00ffff', magenta: 'ff00ff', fuchsia: 'ff00ff', gray: '808080', grey: '808080',
360 orange: 'ffa500', purple: '800080', pink: 'ffc0cb', brown: 'a52a2a', navy: '000080', teal: '008080',
361 lime: '00ff00', maroon: '800000', olive: '808000', silver: 'c0c0c0', gold: 'ffd700', skyblue: '87ceeb',
362 steelblue: '4682b4', royalblue: '4169e1', dodgerblue: '1e90ff', tomato: 'ff6347', coral: 'ff7f50',
363 salmon: 'fa8072', crimson: 'dc143c', firebrick: 'b22222', indigo: '4b0082', violet: 'ee82ee',
364 orchid: 'da70d6', plum: 'dda0dd', tan: 'd2b48c', khaki: 'f0e68c', wheat: 'f5deb3', beige: 'f5f5dc',
365 ivory: 'fffff0', chocolate: 'd2691e', sienna: 'a0522d', saddlebrown: '8b4513', turquoise: '40e0d0',
366 seagreen: '2e8b57', forestgreen: '228b22', darkgreen: '006400', darkblue: '00008b', darkred: '8b0000',
367 lightgray: 'd3d3d3', lightgrey: 'd3d3d3', darkgray: 'a9a9a9', darkgrey: 'a9a9a9', dimgray: '696969',
368 slategray: '708090', lightblue: 'add8e6', lightgreen: '90ee90', hotpink: 'ff69b4', limegreen: '32cd32',
369}
370
371// [r, g, b, a] in 0..255 and 0..1; undefined for none.
372export function parseColor(s: string | undefined, current: string): Rgba | undefined {
373 const v = s?.trim().toLowerCase()
374 if (!v || v === 'none' || v === 'transparent') return undefined
375 if (v === 'currentcolor') return parseColor(current, '#000')
376 const hex = /^#([\da-f]{3,8})$/.exec(v)?.[1] ?? CSS[v]
377 if (hex && [3, 4, 6, 8].includes(hex.length)) {
378 const h = hex.length <= 4 ? [...hex].map(c => c + c).join('') : hex
379 const n = (k: number) => parseInt(h.slice(k, k + 2), 16)
380
381 return [n(0), n(2), n(4), h.length === 8 ? n(6) / 255 : 1]
382 }
383 const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(v)
384 if (fn) {
385 const parts = fn[2]!.split(/[\s,/]+/).filter(Boolean)
386 const val = (p: string | undefined, scale: number) => (p === undefined ? 1 : p.endsWith('%') ? (parseFloat(p) / 100) * scale : parseFloat(p))
387 const a = parts[3] === undefined ? 1 : val(parts[3], 1)
388 if (fn[1]!.startsWith('rgb')) return [val(parts[0], 255), val(parts[1], 255), val(parts[2], 255), a]
389 const [hh = 0, ss = 0, ll = 0] = [parseFloat(parts[0] ?? '0'), val(parts[1], 1), val(parts[2], 1)]
390 const f = (k: number) => {
391 const t = (k + hh / 30) % 12
392
393 return 255 * (ll - ss * Math.min(ll, 1 - ll) * Math.max(-1, Math.min(t - 3, 9 - t, 1)))
394 }
395
396 return [f(0), f(8), f(4), a]
397 }
398 // The style's own tone names: `fill="ok"`, `stroke="accent"`.
399 const themed = HEX_OF[tone(v) ?? '']
400
401 return themed ? parseColor(themed, current) : undefined
402}
403
404type Paint = Rgba | ((x: number, y: number) => Rgba)
405
406type Stop = { at: number; rgba: Rgba }
407
408function stopsOf(el: El, ids: Map<string, El>, current: string, seen = new Set<El>()): Stop[] {
409 const own = el.kids
410 .filter(k => k.name === 'stop')
411 .map(k => {
412 const p = props(k.attrs)
413 const at = p.offset?.endsWith('%') ? parseFloat(p.offset) / 100 : Number(p.offset ?? 0)
414 const c = parseColor(p['stop-color'] ?? 'black', current) ?? [0, 0, 0, 0]
415 const op = Number(p['stop-opacity'] ?? 1)
416
417 return { at: Math.max(0, Math.min(1, at || 0)), rgba: [c[0], c[1], c[2], c[3] * op] as Rgba }
418 })
419 if (own.length > 0) return own
420 const ref = ids.get(hrefOf(el.attrs) ?? '')
421 if (!ref || seen.has(ref)) return []
422 seen.add(el)
423
424 return stopsOf(ref, ids, current, seen)
425}
426
427function at(stops: Stop[], t: number): Rgba {
428 t = Math.max(0, Math.min(1, t))
429 let lo = stops[0]!
430 for (const s of stops) {
431 if (s.at >= t) {
432 const span = s.at - lo.at
433 const f = span > 0 ? (t - lo.at) / span : 1
434
435 return lo.rgba.map((v, k) => v + ((s.rgba[k] ?? v) - v) * f) as Rgba
436 }
437 lo = s
438 }
439
440 return lo.rgba
441}
442
443function gradient(el: El, ids: Map<string, El>, current: string, box: [number, number, number, number], m: M, alpha: number): Paint | undefined {
444 const stops = stopsOf(el, ids, current).sort((a, b) => a.at - b.at)
445 if (stops.length === 0) return undefined
446 if (stops.length === 1) return withAlpha(stops[0]!.rgba, alpha)
447 const a = el.attrs
448 const isUser = a.gradientUnits === 'userSpaceOnUse'
449 const [bx, by, bw, bh] = box
450 // A coordinate in the gradient's units, to user space.
451 const frac = (v: string | undefined, dflt: number) => (v === undefined ? dflt : v.endsWith('%') ? parseFloat(v) / 100 : Number(v))
452 const ux = (v: string | undefined, dflt: number) => (isUser ? (v?.endsWith('%') ? bx + frac(v, dflt) * bw : Number(v ?? dflt * bw + bx)) : bx + frac(v, dflt) * bw)
453 const uy = (v: string | undefined, dflt: number) => (isUser ? (v?.endsWith('%') ? by + frac(v, dflt) * bh : Number(v ?? dflt * bh + by)) : by + frac(v, dflt) * bh)
454 const gm = mul(m, parseTransform(a.gradientTransform))
455 if (el.name === 'linearGradient') {
456 const p1 = apply(gm, [ux(a.x1, 0), uy(a.y1, 0)])
457 const p2 = apply(gm, [ux(a.x2, 1), uy(a.y2, 0)])
458 const dx = p2[0] - p1[0]
459 const dy = p2[1] - p1[1]
460 const len2 = dx * dx + dy * dy || 1
461
462 return (x, y) => withAlpha(at(stops, ((x - p1[0]) * dx + (y - p1[1]) * dy) / len2), alpha)
463 }
464 const c = apply(gm, [ux(a.cx, 0.5), uy(a.cy, 0.5)])
465 const r = frac(a.r, 0.5)
466 const rx = (isUser ? r : r * bw) * Math.hypot(gm[0], gm[1]) || 1
467 const ry = (isUser ? r : r * bh) * Math.hypot(gm[2], gm[3]) || 1
468
469 return (x, y) => withAlpha(at(stops, Math.hypot((x - c[0]) / rx, (y - c[1]) / ry)), alpha)
470}
471
472function withAlpha(c: Rgba, alpha: number): Rgba {
473 return [c[0], c[1], c[2], c[3] * alpha]
474}
475
476function hrefOf(a: Attrs): string | undefined {
477 return (a.href ?? a['xlink:href'])?.replace(/^#/, '')
478}
479
480// Presentation attributes with the `style` declarations laid over them.
481function props(a: Attrs): Attrs {
482 if (!a.style) return a
483 const out = { ...a }
484 for (const decl of a.style.split(';')) {
485 const [k, ...v] = decl.split(':')
486 if (k?.trim() && v.length) out[k.trim()] = v.join(':').trim()
487 }
488
489 return out
490}
491
492// ── the raster: premultiplied RGBA samples, S×S per pixel ───────────────
493
494// A crossing's cost in a fill (finding it, sorting it, painting from it),
495// in units of reading an edge.
496const CROSSING_WORK = 16
497
498class Raster {
499 readonly buf: Float32Array
500
501 constructor(
502 readonly w: number,
503 readonly h: number,
504 ) {
505 this.buf = new Float32Array(w * h * 4)
506 }
507
508 // Scanline fill: each sample row's crossings, inside by the fill rule.
509 // Answers the work it took, in edges read, crossings sorted and samples
510 // painted, or Infinity where that passed `budget`: the fill stops at the
511 // row where it does.
512 fill(polys: readonly Pt[][], rule: string, paint: Paint, budget = Infinity): number {
513 type Edge = { x0: number; y0: number; x1: number; y1: number; dir: number }
514 const edges: Edge[] = []
515 let top = Infinity
516 let bottom = -Infinity
517 for (const pts of polys) {
518 for (let k = 0; k < pts.length; k++) {
519 const a = pts[k]!
520 const b = pts[(k + 1) % pts.length]!
521 if (a[1] === b[1]) continue
522 edges.push(a[1] < b[1] ? { x0: a[0], y0: a[1], x1: b[0], y1: b[1], dir: 1 } : { x0: b[0], y0: b[1], x1: a[0], y1: a[1], dir: -1 })
523 top = Math.min(top, a[1], b[1])
524 bottom = Math.max(bottom, a[1], b[1])
525 }
526 }
527 // A point that is not a number leaves the shape unpainted.
528 if (edges.length === 0 || Number.isNaN(top) || Number.isNaN(bottom)) return 0
529 const isEvenOdd = rule === 'evenodd'
530 const solid = typeof paint === 'function' ? undefined : paint
531 const from = Math.max(0, Math.floor(top))
532 const to = Math.min(this.h - 1, Math.ceil(bottom))
533 // Edges by their top, each joining the rows' active list when the rows
534 // reach it and leaving it past its bottom: a row reads only the edges
535 // that cross it.
536 edges.sort((a, b) => a.y0 - b.y0)
537 let work = edges.length
538 let next = 0
539 const active: Edge[] = []
540 const xs: Array<[number, number]> = []
541 for (let row = from; row <= to; row++) {
542 const y = row + 0.5
543 while (next < edges.length && edges[next]!.y0 <= y) active.push(edges[next++]!)
544 let kept = 0
545 for (const e of active) if (y < e.y1) active[kept++] = e
546 active.length = kept
547 xs.length = 0
548 for (const e of active) xs.push([e.x0 + ((y - e.y0) * (e.x1 - e.x0)) / (e.y1 - e.y0), e.dir])
549 work += kept * CROSSING_WORK
550 if (work > budget) return Infinity
551 if (xs.length < 2) continue
552 xs.sort((a, b) => a[0] - b[0])
553 let wind = 0
554 for (let k = 0; k < xs.length - 1; k++) {
555 wind += xs[k]![1]
556 const inside = isEvenOdd ? wind % 2 !== 0 : wind !== 0
557 if (!inside) continue
558 const c0 = Math.max(0, Math.ceil(xs[k]![0] - 0.5))
559 const c1 = Math.min(this.w, Math.ceil(xs[k + 1]![0] - 0.5))
560 for (let col = c0; col < c1; col++) this.over(col, row, solid ?? (paint as (x: number, y: number) => Rgba)(col + 0.5, y))
561 work += Math.max(0, c1 - c0)
562 }
563 }
564
565 return work
566 }
567
568 over(x: number, y: number, [r, g, b, a]: Rgba) {
569 if (a <= 0) return
570 const i = (y * this.w + x) * 4
571 const keep = 1 - a
572 const buf = this.buf
573 buf[i] = r * a + buf[i]! * keep
574 buf[i + 1] = g * a + buf[i + 1]! * keep
575 buf[i + 2] = b * a + buf[i + 2]! * keep
576 buf[i + 3] = a + buf[i + 3]! * keep
577 }
578}
579
580function orient(pts: Pt[]): Pt[] {
581 let area = 0
582 for (let k = 0; k < pts.length; k++) {
583 const a = pts[k]!
584 const b = pts[(k + 1) % pts.length]!
585 area += a[0] * b[1] - b[0] * a[1]
586 }
587
588 return area < 0 ? [...pts].reverse() : pts
589}
590
591// A stroke as filled pieces, every one wound the same way so the nonzero
592// rule unions them: a quad per segment, a disc per join and round cap.
593function strokePolys(pts: readonly Pt[], isClosed: boolean, w: number, cap: string): Pt[][] {
594 const h = w / 2
595 const out: Pt[][] = []
596 const line = isClosed ? [...pts, pts[0]!] : pts
597 const disc = (c: Pt) => orient(ellipsePts(c[0], c[1], h, h, () => Math.max(6, Math.min(16, Math.ceil(h * 2)))))
598 for (let k = 0; k + 1 < line.length; k++) {
599 let [ax, ay] = line[k]!
600 let [bx, by] = line[k + 1]!
601 const len = Math.hypot(bx - ax, by - ay)
602 if (len === 0) continue
603 const ux = (bx - ax) / len
604 const uy = (by - ay) / len
605 if (cap === 'square' && !isClosed) {
606 if (k === 0) [ax, ay] = [ax - ux * h, ay - uy * h]
607 if (k === line.length - 2) [bx, by] = [bx + ux * h, by + uy * h]
608 }
609 const nx = -uy * h
610 const ny = ux * h
611 out.push(orient([[ax + nx, ay + ny], [bx + nx, by + ny], [bx - nx, by - ny], [ax - nx, ay - ny]]))
612 }
613 if (h >= 1) {
614 const inner = isClosed ? line.slice(0, -1) : line.slice(1, -1)
615 for (const p of inner) out.push(disc(p))
616 if (cap === 'round' && !isClosed && line.length > 1) out.push(disc(line[0]!), disc(line[line.length - 1]!))
617 }
618
619 return out
620}
621
622// ── the walk: elements to fills, strokes and text ───────────────────────
623
624type Ctx = {
625 m: M
626 fill: string
627 stroke: string
628 sw: string
629 opacity: number
630 fillOpacity: number
631 strokeOpacity: number
632 rule: string
633 cap: string
634 fontSize: number
635 anchor: string
636 isBold: boolean
637 color: string
638}
639
640type Label = { x: number; y: number; text: string; anchor: string; rgba?: Rgba; isBold: boolean }
641
642// SMIL's elements: the frame drawn carries what they say (see below).
643const ANIMS = new Set(['animate', 'set', 'animateTransform', 'animateMotion', 'animateColor'])
644const SKIP = new Set(['defs', 'linearGradient', 'radialGradient', 'title', 'desc', 'metadata', 'style', 'clipPath', 'mask', 'pattern', 'symbol', 'marker', 'filter', 'script', 'mpath', ...ANIMS])
645
646// What one drawing may spend: elements walked (a `use` walks its target
647// again, so a few lines can ask for billions), and raster work, in edges
648// read per sample row, crossings, samples painted and stroke points built.
649// A unit measured 11 to 19 ns, so the most is about 150 to 230 ms (400
650// stroked circles take 8.7 million); past it the drawing stops where it
651// is, as if the rest had not streamed in yet.
652const SVG_MAX_VISITS = 20_000
653const SVG_MAX_WORK = 12_000_000
654// A stroke point's cost, in edges read: building its quad and disc takes
655// about as long as reading this many edges.
656const STROKE_POINT_WORK = 100
657
658type Scene = {
659 raster: Raster
660 ids: Map<string, El>
661 vb: [number, number, number, number]
662 S: number
663 labels: Label[]
664 drawn: number
665 visits: number
666 work: number
667}
668
669function len(v: string | undefined, ref: number, fontSize: number, dflt = 0): number {
670 if (v === undefined || v === '') return dflt
671 const n = parseFloat(v)
672 if (!Number.isFinite(n)) return dflt
673 if (v.endsWith('%')) return (n / 100) * ref
674 if (/em$/.test(v)) return n * fontSize
675
676 return n
677}
678
679function inherit(ctx: Ctx, attrs: Attrs): Ctx {
680 const p = props(attrs)
681 const fontSize = len(p['font-size'], ctx.fontSize, ctx.fontSize, ctx.fontSize)
682
683 return {
684 m: p.transform ? mul(ctx.m, parseTransform(p.transform)) : ctx.m,
685 fill: p.fill ?? ctx.fill,
686 stroke: p.stroke ?? ctx.stroke,
687 sw: p['stroke-width'] ?? ctx.sw,
688 opacity: ctx.opacity * Number(p.opacity ?? 1),
689 fillOpacity: p['fill-opacity'] !== undefined ? Number(p['fill-opacity']) : ctx.fillOpacity,
690 strokeOpacity: p['stroke-opacity'] !== undefined ? Number(p['stroke-opacity']) : ctx.strokeOpacity,
691 rule: p['fill-rule'] ?? ctx.rule,
692 cap: p['stroke-linecap'] ?? ctx.cap,
693 fontSize: Number.isFinite(fontSize) ? fontSize : ctx.fontSize,
694 anchor: p['text-anchor'] ?? ctx.anchor,
695 isBold: p['font-weight'] !== undefined ? /bold|[6-9]00/.test(p['font-weight']) : ctx.isBold,
696 color: p.color ?? ctx.color,
697 }
698}
699
700function shapePolys(el: El, ctx: Ctx, scene: Scene, segs: Segs): Poly[] {
701 const a = el.attrs
702 const [, , vw, vh] = scene.vb
703 const diag = Math.hypot(vw, vh) / Math.SQRT2
704 const L = (k: string, ref: number) => len(a[k], ref, ctx.fontSize)
705 switch (el.name) {
706 case 'rect': {
707 const x = L('x', vw)
708 const y = L('y', vh)
709 const w = L('width', vw)
710 const h = L('height', vh)
711 if (w <= 0 || h <= 0) return []
712 let rx = a.rx !== undefined ? L('rx', vw) : a.ry !== undefined ? L('ry', vh) : 0
713 let ry = a.ry !== undefined ? L('ry', vh) : rx
714 rx = Math.min(rx, w / 2)
715 ry = Math.min(ry, h / 2)
716 if (rx <= 0 || ry <= 0) return [{ pts: [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], isClosed: true }]
717 const q = Math.PI / 2
718
719 return [
720 {
721 pts: [
722 ...ellipsePts(x + w - rx, y + ry, rx, ry, segs, -q, 0),
723 ...ellipsePts(x + w - rx, y + h - ry, rx, ry, segs, 0, q),
724 ...ellipsePts(x + rx, y + h - ry, rx, ry, segs, q, 2 * q),
725 ...ellipsePts(x + rx, y + ry, rx, ry, segs, 2 * q, 3 * q),
726 ],
727 isClosed: true,
728 },
729 ]
730 }
731 case 'circle':
732 case 'ellipse': {
733 const rx = el.name === 'circle' ? L('r', diag) : L('rx', vw)
734 const ry = el.name === 'circle' ? rx : L('ry', vh)
735 if (rx <= 0 || ry <= 0) return []
736 const pts = ellipsePts(L('cx', vw), L('cy', vh), rx, ry, segs)
737
738 return [{ pts: pts.slice(0, -1), isClosed: true }]
739 }
740 case 'line':
741 return [{ pts: [[L('x1', vw), L('y1', vh)], [L('x2', vw), L('y2', vh)]], isClosed: false }]
742 case 'polyline':
743 case 'polygon': {
744 const v = numList(a.points)
745 const pts: Pt[] = []
746 for (let k = 0; k + 1 < v.length; k += 2) pts.push([v[k]!, v[k + 1]!])
747
748 return pts.length > 1 ? [{ pts, isClosed: el.name === 'polygon' }] : []
749 }
750 case 'path':
751 return pathPolys(a.d ?? '', segs)
752 default:
753 return []
754 }
755}
756
757function paintOf(spec: string, ctx: Ctx, alpha: number, scene: Scene, polys: Poly[]): Paint | undefined {
758 const url = /^url\(\s*['"]?#([^'")]+)['"]?\s*\)/.exec(spec.trim())
759 if (url) {
760 const el = scene.ids.get(url[1]!)
761 if (!el || (el.name !== 'linearGradient' && el.name !== 'radialGradient')) return undefined
762 const all = polys.flatMap(p => p.pts)
763 const xs = all.map(p => p[0])
764 const ys = all.map(p => p[1])
765 const box: [number, number, number, number] = [Math.min(...xs), Math.min(...ys), Math.max(...xs) - Math.min(...xs) || 1, Math.max(...ys) - Math.min(...ys) || 1]
766
767 return gradient(el, scene.ids, ctx.color, box, ctx.m, alpha)
768 }
769 const c = parseColor(spec, ctx.color)
770
771 return c && withAlpha(c, alpha)
772}
773
774function textOf(el: El): string {
775 return (el.text + el.kids.map(textOf).join('')).replace(/\s+/g, ' ')
776}
777
778function walk(els: readonly El[], ctx: Ctx, scene: Scene, depth = 0) {
779 if (depth > 32) return
780 for (const el of els) {
781 if (scene.visits >= SVG_MAX_VISITS || scene.work >= SVG_MAX_WORK) return
782 scene.visits += 1
783 if (SKIP.has(el.name)) continue
784 const p = props(el.attrs)
785 if (p.display === 'none' || p.visibility === 'hidden') continue
786 const c = inherit(ctx, el.attrs)
787 if (el.name === 'g' || el.name === 'svg' || el.name === 'a' || el.name === 'switch') {
788 walk(el.kids, c, scene, depth + 1)
789 continue
790 }
791 if (el.name === 'use') {
792 const ref = scene.ids.get(hrefOf(el.attrs) ?? '')
793 if (!ref) continue
794 const at: M = [1, 0, 0, 1, len(el.attrs.x, scene.vb[2], c.fontSize), len(el.attrs.y, scene.vb[3], c.fontSize)]
795 walk(ref.name === 'symbol' ? ref.kids : [ref], { ...c, m: mul(c.m, at) }, scene, depth + 1)
796 continue
797 }
798 if (el.name === 'text') {
799 const text = textOf(el).trim()
800 const tspan = el.kids.find(k => k.name === 'tspan')
801 const x = len(el.attrs.x ?? tspan?.attrs.x, scene.vb[2], c.fontSize)
802 const y = len(el.attrs.y ?? tspan?.attrs.y, scene.vb[3], c.fontSize)
803 // Glyphs are a cell tall whatever the font size: the baseline finds the row.
804 const [dx, dy] = apply(c.m, [x, y])
805 const fill = parseColor(c.fill, c.color)
806 if (text) scene.labels.push({ x: dx, y: dy, text, anchor: c.anchor, rgba: fill, isBold: c.isBold })
807 continue
808 }
809 const scale = Math.sqrt(Math.abs(c.m[0] * c.m[3] - c.m[1] * c.m[2])) || 1
810 const segs: Segs = l => Math.max(2, Math.min(96, Math.ceil((l * scale) / (scene.S * 0.75))))
811 const polys = shapePolys(el, c, scene, segs)
812 if (polys.length === 0) continue
813 scene.drawn += 1
814 const spend = (work: number) => (scene.work = work === Infinity ? SVG_MAX_WORK : scene.work + work)
815 const fill = el.name === 'line' ? undefined : paintOf(c.fill, c, c.opacity * c.fillOpacity, scene, polys)
816 if (fill) spend(scene.raster.fill(polys.map(poly => poly.pts.map(pt => apply(c.m, pt))), c.rule, fill, SVG_MAX_WORK - scene.work))
817 const stroke = scene.work < SVG_MAX_WORK ? paintOf(c.stroke, c, c.opacity * c.strokeOpacity, scene, polys) : undefined
818 if (stroke) {
819 // Each point of a stroke builds a quad and a disc of up to 17 points.
820 spend(polys.reduce((n, poly) => n + poly.pts.length, 0) * STROKE_POINT_WORK)
821 if (scene.work >= SVG_MAX_WORK) continue
822 // Hairlines widen to one pixel rather than vanish.
823 const w = Math.max(scene.S, len(c.sw, Math.hypot(scene.vb[2], scene.vb[3]) / Math.SQRT2, c.fontSize, 1) * scale)
824 const pieces = polys.flatMap(poly => strokePolys(poly.pts.map(pt => apply(c.m, pt)), poly.isClosed, w, c.cap))
825 spend(scene.raster.fill(pieces, 'nonzero', stroke, SVG_MAX_WORK - scene.work))
826 }
827 }
828}
829
830function indexIds(els: readonly El[], ids: Map<string, El>) {
831 for (const el of els) {
832 if (el.attrs.id) ids.set(el.attrs.id, el)
833 indexIds(el.kids, ids)
834 }
835}
836
837// ── SMIL: animate, set, animateTransform, animateMotion ─────────────────
838// The tree as it stands `t` seconds in: each animation's value laid over
839// its target's attributes, so the walk draws a frame as it draws a still.
840
841const ANIM_TAG = /<(?:animate|set|animateTransform|animateMotion|animateColor)[\s/>]/
842const COLOR_ATTRS = new Set(['fill', 'stroke', 'stop-color', 'color', 'flood-color', 'lighting-color'])
843
844type Anim = { el: El; target: El; begin: number; dur: number; end: number; ids: Map<string, El> }
845
846// Seconds in a clock value: `2s`, `150ms`, `1.5min`, `00:01.5`, `3`.
847function seconds(v: string | undefined): number | undefined {
848 const s = v?.trim()
849 if (!s) return undefined
850 if (s === 'indefinite') return Infinity
851 const clock = /^(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(s)
852 if (clock) return Number(clock[1] ?? 0) * 3600 + Number(clock[2]) * 60 + Number(clock[3])
853 const m = /^([-+]?\d*\.?\d+)\s*(h|min|s|ms)?$/.exec(s)
854 if (!m) return undefined
855 const unit: Record<string, number> = { h: 3600, min: 60, s: 1, ms: 0.001 }
856
857 return Number(m[1]) * (unit[m[2] ?? 's'] ?? 1)
858}
859
860// Every animation with its target and its interval. A begin it cannot
861// resolve (an event such as `click`) never comes: the target keeps its own
862// values, as a page would until the event.
863function timings(els: readonly El[]): Anim[] {
864 const ids = new Map<string, El>()
865 const found: Array<{ el: El; parent?: El }> = []
866 const visit = (list: readonly El[], parent?: El) => {
867 for (const el of list) {
868 if (el.attrs.id) ids.set(el.attrs.id, el)
869 if (ANIMS.has(el.name)) found.push({ el, parent })
870 else visit(el.kids, el)
871 }
872 }
873 visit(els)
874 const byId = new Map(found.filter(f => f.el.attrs.id).map(f => [f.el.attrs.id!, f]))
875 const memo = new Map<El, { begin: number; dur: number; end: number }>()
876 const resolve = (f: { el: El }, depth = 0): { begin: number; dur: number; end: number } => {
877 const known = memo.get(f.el)
878 if (known) return known
879 const never = { begin: Infinity, dur: Infinity, end: Infinity }
880 if (depth > 16) return never
881 const a = f.el.attrs
882 let begin = Infinity
883 for (const part of (a.begin ?? '0s').split(';')) {
884 const p = part.trim()
885 const offset = seconds(p)
886 if (offset !== undefined) {
887 if (Number.isFinite(offset)) begin = Math.min(begin, offset)
888 continue
889 }
890 const sync = /^([\w-]+)\.(begin|end)\s*(?:([+-])\s*(.+))?$/.exec(p)
891 const other = sync && byId.get(sync[1]!)
892 if (!sync || !other) continue
893 const r = resolve(other, depth + 1)
894 const shift = (seconds(sync[4]) ?? 0) * (sync[3] === '-' ? -1 : 1)
895 const at = (sync[2] === 'begin' ? r.begin : r.end) + shift
896 if (Number.isFinite(at)) begin = Math.min(begin, at)
897 }
898 if (!Number.isFinite(begin)) {
899 memo.set(f.el, never)
900
901 return never
902 }
903 // No `dur` is an indefinite simple duration: the value holds from its begin.
904 const dur = seconds(a.dur) ?? Infinity
905 const count = a.repeatCount
906 const repeatDur = seconds(a.repeatDur)
907 let active = dur
908 if (count === 'indefinite') active = Infinity
909 else if (Number(count) > 0) active = dur * Number(count)
910 if (repeatDur !== undefined) active = count ? Math.min(active, repeatDur) : repeatDur
911 const end = seconds(a.end)
912 if (end !== undefined && end > begin) active = Math.min(active, end - begin)
913 const r = { begin, dur, end: begin + active }
914 memo.set(f.el, r)
915
916 return r
917 }
918
919 return found.flatMap(f => {
920 const target = ids.get(hrefOf(f.el.attrs) ?? '') ?? f.parent
921 if (!target) return []
922 const r = resolve(f)
923
924 return Number.isFinite(r.begin) ? [{ el: f.el, target, ids, ...r }] : []
925 })
926}
927
928// When the drawing stops changing (Infinity for a loop), and the time of
929// the still it rests on: the last moment any finite animation changes.
930function settle(anims: readonly Anim[]): { endS: number; restS: number } {
931 const changes = anims.map(a => (Number.isFinite(a.dur) ? a.end : a.begin))
932 const finite = changes.filter(Number.isFinite)
933
934 return { endS: Math.max(0, ...changes), restS: Math.max(0, ...finite) }
935}
936
937// Whether a drawing animates, and for how long; undefined for a still.
938export function animation(body: string): { endMs: number } | undefined {
939 if (!ANIM_TAG.test(body)) return undefined
940 const anims = timings(rootOf(body, {}).els)
941 if (anims.length === 0) return undefined
942
943 return { endMs: settle(anims).endS * 1000 }
944}
945
946function lerpText(a: string, b: string, f: number): string | undefined {
947 const na = a.match(NUM) ?? []
948 const nb = b.match(NUM) ?? []
949 if (na.length === 0 || na.length !== nb.length) return undefined
950 let k = 0
951
952 return a.replace(NUM, () => {
953 const v = Number(na[k]) + (Number(nb[k]) - Number(na[k])) * f
954 k += 1
955
956 return String(+v.toFixed(4))
957 })
958}
959
960function lerp(name: string, a: string, b: string, f: number): string {
961 if (COLOR_ATTRS.has(name)) {
962 const ca = parseColor(a, '#000')
963 const cb = parseColor(b, '#000')
964 if (ca && cb) {
965 const c = ca.map((v, k) => v + ((cb[k] ?? v) - v) * f)
966
967 return `rgba(${Math.round(c[0]!)},${Math.round(c[1]!)},${Math.round(c[2]!)},${+c[3]!.toFixed(3)})`
968 }
969 }
970
971 // What does not interpolate switches halfway, as a discrete value would.
972 return lerpText(a, b, f) ?? (f < 0.5 ? a : b)
973}
974
975// A cubic-bezier ease: y at x, by bisection.
976function ease([x1 = 0, y1 = 0, x2 = 1, y2 = 1]: readonly number[], x: number): number {
977 const at = (t: number, p1: number, p2: number) => 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t
978 let lo = 0
979 let hi = 1
980 for (let k = 0; k < 24; k++) {
981 const mid = (lo + hi) / 2
982 if (at(mid, x1, x2) < x) lo = mid
983 else hi = mid
984 }
985
986 return at((lo + hi) / 2, y1, y2)
987}
988
989// Where `p` (0..1 of a simple duration) falls among the values.
990function pick(a: Attrs, values: readonly string[], p: number, mode: string, mix: (x: string, y: string, f: number) => string): string {
991 const n = values.length
992 if (n === 1) return values[0]!
993 const given = a.keyTimes?.split(';').map(Number)
994 const times = given?.length === n && given.every(Number.isFinite) ? given : undefined
995 if (mode === 'discrete') {
996 if (!times) return values[Math.min(n - 1, Math.floor(p * n))]!
997 let i = 0
998 times.forEach((k, j) => {
999 if (k <= p) i = j
1000 })
1001
1002 return values[i]!
1003 }
1004 const kt = times ?? values.map((_, i) => i / (n - 1))
1005 let i = 0
1006 while (i < n - 2 && p >= kt[i + 1]!) i++
1007 const span = kt[i + 1]! - kt[i]!
1008 let f = Math.max(0, Math.min(1, span > 0 ? (p - kt[i]!) / span : 1))
1009 if (mode === 'spline') {
1010 const spline = a.keySplines?.split(';')[i]
1011 if (spline) f = ease(numList(spline), f)
1012 }
1013
1014 return mix(values[i]!, values[i + 1]!, f)
1015}
1016
1017// The progress through the simple duration at `t`, or undefined while the
1018// animation has no say (not begun, or ended without `fill="freeze"`).
1019function progress(an: Anim, t: number): number | undefined {
1020 if (t < an.begin) return undefined
1021 if (t < an.end) return Number.isFinite(an.dur) && an.dur > 0 ? ((t - an.begin) % an.dur) / an.dur : 0
1022 if (an.el.attrs.fill !== 'freeze') return undefined
1023 if (!Number.isFinite(an.dur) || an.dur <= 0) return 0
1024 const span = an.end - an.begin
1025 const rest = span - Math.floor(span / an.dur) * an.dur
1026
1027 // Frozen at a whole number of repeats: the last value, not the first.
1028 return rest < 1e-9 ? 1 : rest / an.dur
1029}
1030
1031function motionAt(an: Anim, p: number): string | undefined {
1032 const a = an.el.attrs
1033 const mpath = an.el.kids.find(k => k.name === 'mpath')
1034 let d = a.path ?? (mpath ? an.ids.get(hrefOf(mpath.attrs) ?? '')?.attrs.d : undefined)
1035 if (!d) {
1036 const pts = a.values?.split(';') ?? [a.from, a.to].filter((v): v is string => v !== undefined)
1037 if (pts.length > 0) d = 'M' + pts.join(' L')
1038 }
1039 const line = pathPolys(d ?? '', () => 16).flatMap(poly => poly.pts)
1040 if (line.length === 0) return undefined
1041 const lens = [0]
1042 for (let k = 1; k < line.length; k++) lens.push(lens[k - 1]! + Math.hypot(line[k]![0] - line[k - 1]![0], line[k]![1] - line[k - 1]![1]))
1043 // Paced: equal distance in equal time, whatever the calcMode.
1044 const want = Math.max(0, Math.min(1, p)) * lens[lens.length - 1]!
1045 let k = 1
1046 while (k < line.length - 1 && lens[k]! < want) k++
1047 const [ax, ay] = line[k - 1] ?? line[0]!
1048 const [bx, by] = line[k] ?? line[0]!
1049 const seg = (lens[k] ?? 0) - (lens[k - 1] ?? 0)
1050 const f = seg > 0 ? (want - lens[k - 1]!) / seg : 0
1051 const x = ax + (bx - ax) * f
1052 const y = ay + (by - ay) * f
1053 const auto = (Math.atan2(by - ay, bx - ax) * 180) / Math.PI
1054 const turn = a.rotate === 'auto' ? auto : a.rotate === 'auto-reverse' ? auto + 180 : Number(a.rotate) || 0
1055
1056 return `translate(${+x.toFixed(3)} ${+y.toFixed(3)})${turn ? ` rotate(${+turn.toFixed(2)})` : ''}`
1057}
1058
1059function animated(els: readonly El[], anims: readonly Anim[], t: number): El[] {
1060 const over = new Map<El, Attrs>()
1061 const motion = new Map<El, string>()
1062 for (const an of anims) {
1063 const p = progress(an, t)
1064 if (p === undefined) continue
1065 const a = an.el.attrs
1066 const base = over.get(an.target) ?? (({ style: _, ...rest }) => rest)(props(an.target.attrs))
1067 over.set(an.target, base)
1068 if (an.el.name === 'animateMotion') {
1069 const m = motionAt(an, p)
1070 if (m) motion.set(an.target, m)
1071 continue
1072 }
1073 const isTransform = an.el.name === 'animateTransform'
1074 const name = isTransform ? 'transform' : a.attributeName
1075 if (!name) continue
1076 const type = a.type ?? 'translate'
1077 const own = isTransform ? '' : base[name] ?? ''
1078 let values: string[]
1079 if (an.el.name === 'set') values = [a.to ?? '']
1080 else if (a.values) values = a.values.split(';').map(v => v.trim()).filter(Boolean)
1081 else if (a.by !== undefined && a.to === undefined) {
1082 const from = a.from ?? (isTransform ? a.by.replace(NUM, '0') : own || '0')
1083 values = [from, sum(from, a.by)]
1084 } else values = [a.from ?? own, a.to ?? own]
1085 if (values.length === 0) continue
1086 const mode = an.el.name === 'set' ? 'discrete' : (a.calcMode ?? 'linear')
1087 const v = pick(a, values, p, mode, (x, y, f) => lerp(name, x, y, f))
1088 if (isTransform) {
1089 const step = `${type}(${v})`
1090 base.transform = a.additive === 'sum' && base.transform ? `${base.transform} ${step}` : step
1091 } else base[name] = a.additive === 'sum' && own ? sum(own, v) : v
1092 }
1093 // Motion lies over every other transform of its target.
1094 for (const [el, m] of motion) {
1095 const attrs = over.get(el)!
1096 attrs.transform = attrs.transform ? `${m} ${attrs.transform}` : m
1097 }
1098 const copy = (el: El): El => ({ ...el, attrs: over.get(el) ?? el.attrs, kids: el.kids.map(copy) })
1099
1100 return els.map(copy)
1101}
1102
1103// Two values added number by number, as `additive="sum"` and `by` ask;
1104// `b` alone where their numbers do not line up.
1105function sum(a: string, b: string): string {
1106 const na = a.match(NUM) ?? []
1107 const nb = b.match(NUM) ?? []
1108 if (na.length === 0 || na.length !== nb.length) return b
1109 let k = 0
1110
1111 return a.replace(NUM, n => String(+(Number(n) + Number(nb[k++])).toFixed(4)))
1112}
1113
1114// ── native surfaces: the markup as written, for a surface that draws SVG ─
1115
1116const OWN = new Set(['title', 'cols', 'rows', 'mode', 'bg', 'background', 'width', 'height'])
1117const CASED: Record<string, string> = { viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio' }
1118
1119function quoteAttr(v: string): string {
1120 return v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
1121}
1122
1123function escapeText(s: string): string {
1124 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
1125}
1126
1127// What a surface's own SVG element is handed holds nothing that reaches
1128// past the drawing: no script or handler (the surfaces strip those too),
1129// and nothing loaded from elsewhere. An animated drawing shows in a frame,
1130// where an external <image> would be fetched, and its URL could carry
1131// whatever a reply was steered into writing there. The cells draw none of
1132// these either.
1133const UNSAFE = new Set(['script', 'foreignObject', 'image', 'feImage', 'iframe', 'style', 'object', 'embed', 'audio', 'video'])
1134
1135function isSafeAttr(k: string, v: string): boolean {
1136 if (/^on/i.test(k)) return false
1137 if (/^(?:xlink:)?href$/i.test(k)) return v.trim().startsWith('#')
1138
1139 return !/url\(\s*['"]?\s*(?!#)/i.test(v)
1140}
1141
1142const TONED = new Set(['fill', 'stroke', 'stop-color', 'flood-color', 'color'])
1143
1144// An attribute as a browser should read it: the style's tone names turned
1145// to colors it knows.
1146function nativeAttr(k: string, v: string): string {
1147 const hex = TONED.has(k) && /^[\w-]+$/.test(v) && !CSS[v.toLowerCase()] ? HEX_OF[tone(v) ?? ''] : undefined
1148
1149 return ` ${k}="${quoteAttr(hex ?? v)}"`
1150}
1151
1152// The markup read as the cells read it and written again, well formed:
1153// unsafe elements left out with all they hold, unsafe attributes dropped,
1154// declarations and comments gone, every open element closed.
1155function nativeMarkup(src: string): string {
1156 let out = ''
1157 const stack: string[] = []
1158 // Elements opened inside an unsafe one, itself included.
1159 let skipped = 0
1160 for (const m of src.matchAll(TOKEN)) {
1161 if (m[5] !== undefined) {
1162 if (!skipped) out += escapeText(decode(m[5]))
1163 continue
1164 }
1165 const name = m[2]
1166 if (!name) continue
1167 if (m[1]) {
1168 if (skipped) {
1169 skipped -= 1
1170 continue
1171 }
1172 // A close with no open element of its name is dropped; one with
1173 // closes what was left open inside it too.
1174 const at = stack.lastIndexOf(name)
1175 if (at === -1) continue
1176 out += stack.splice(at).reverse().map(open => `</${open}>`).join('')
1177 continue
1178 }
1179 const attrs = xmlAttrs(m[3] ?? '')
1180 // An animation of a link could set it to an address elsewhere.
1181 const isUnsafe = UNSAFE.has(name) || (ANIMS.has(name) && /href$/i.test(attrs.attributeName ?? ''))
1182 if (skipped || isUnsafe) {
1183 if (!m[4]) skipped += 1
1184 continue
1185 }
1186 const safe = Object.entries(attrs).filter(([k, v]) => isSafeAttr(k, v))
1187 out += `<${name}${safe.map(([k, v]) => nativeAttr(k, v)).join('')}${m[4] ? '/' : ''}>`
1188 if (!m[4]) stack.push(name)
1189 }
1190
1191 return out + stack.reverse().map(name => `</${name}>`).join('')
1192}
1193
1194// The widest a surface's own drawing is set, in cells of 8 CSS pixels.
1195const NATIVE_MAX_COLS = 200
1196
1197// What a surface with its own SVG element is handed: a whole document.
1198export function nativeSvg(body: string, attrs: Attrs): { source: string; alt: string; width: number; isAnimated: boolean } {
1199 const { els } = rootOf(body, {})
1200 const isWhole = els.length === 1 && els[0]!.name === 'svg'hooks/text.ts 215 lines1// Styled text as plain data: everything the widgets draw is rows of runs,
2// which register.tsx turns into Text elements.
3
4export type Style = {
5 fg?: string
6 bg?: string
7 bold?: boolean
8 italic?: boolean
9 underline?: boolean
10 strike?: boolean
11 dim?: boolean
12 inverse?: boolean
13}
14
15export type Run = Style & { text: string }
16
17// `lead` is a fixed-width gutter (a bullet, a key) the wrapped `runs` hang from.
18export type Row = { lead?: Run[]; runs: Run[]; wrap?: boolean }
19
20// Tone names the model writes, mapped to the person's theme keys.
21const TONES: Record<string, string> = {
22 ok: 'success',
23 success: 'success',
24 done: 'success',
25 pass: 'success',
26 warn: 'warning',
27 warning: 'warning',
28 err: 'error',
29 error: 'error',
30 fail: 'error',
31 info: 'suggestion',
32 note: 'suggestion',
33 tip: 'autoAccept',
34 accent: 'claude',
35 claude: 'claude',
36 plan: 'planMode',
37 dim: 'inactive',
38 muted: 'inactive',
39}
40
41export const SERIES = ['claude', 'suggestion', 'success', 'autoAccept', 'warning', 'planMode']
42
43const NAMED = /^(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(Bright)?$/i
44const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
45
46export function tone(name: string | undefined): string | undefined {
47 if (!name) return undefined
48 const key = name.trim()
49 if (TONES[key.toLowerCase()]) return TONES[key.toLowerCase()]
50 if (HEX.test(key) || NAMED.test(key)) return key
51 if (/^[a-zA-Z]+$/.test(key)) return key // a theme key the person's theme holds
52
53 return undefined
54}
55
56export function isTone(name: string): boolean {
57 return Boolean(TONES[name.toLowerCase()])
58}
59
60function isWide(c: number): boolean {
61 return (
62 (c >= 0x1100 && c <= 0x115f) ||
63 (c >= 0x2e80 && c <= 0xa4cf) ||
64 (c >= 0xac00 && c <= 0xd7a3) ||
65 (c >= 0xf900 && c <= 0xfaff) ||
66 (c >= 0xfe30 && c <= 0xfe4f) ||
67 (c >= 0xff00 && c <= 0xff60) ||
68 (c >= 0xffe0 && c <= 0xffe6) ||
69 (c >= 0x1f300 && c <= 0x1faff) ||
70 (c >= 0x20000 && c <= 0x3fffd)
71 )
72}
73
74// Terminal cells a string takes: wide CJK and emoji count two, joiners none.
75export function width(s: string): number {
76 let w = 0
77 for (const ch of s) {
78 const c = ch.codePointAt(0) ?? 0
79 if (c === 0x200d || (c >= 0x300 && c <= 0x36f) || (c >= 0xfe00 && c <= 0xfe0f)) continue
80 w += isWide(c) ? 2 : 1
81 }
82
83 return w
84}
85
86export function runsWidth(runs: readonly Run[]): number {
87 return runs.reduce((n, r) => n + width(r.text), 0)
88}
89
90export function clip(s: string, cells: number): string {
91 if (width(s) <= cells) return s
92 let out = ''
93 for (const ch of s) {
94 if (width(out + ch) > cells - 1) break
95 out += ch
96 }
97
98 return out + '…'
99}
100
101export function pad(s: string, cells: number, align: 'left' | 'right' | 'center' = 'left'): string {
102 const gap = Math.max(0, cells - width(s))
103 if (align === 'right') return ' '.repeat(gap) + s
104 if (align === 'center') return ' '.repeat(Math.floor(gap / 2)) + s + ' '.repeat(Math.ceil(gap / 2))
105
106 return s + ' '.repeat(gap)
107}
108
109// The first number in a cell ("120ms", "1,204", "-3.5%"), or undefined.
110export function num(s: string): number | undefined {
111 const m = /-?\d[\d,]*(?:\.\d+)?|-?\.\d+/.exec(s)
112 if (!m) return undefined
113 const n = Number(m[0].replace(/,/g, ''))
114
115 return Number.isFinite(n) ? n : undefined
116}
117
118export function nums(s: string): number[] {
119 return s
120 .split(/[\s,;]+/)
121 .map(t => num(t))
122 .filter((n): n is number => n !== undefined)
123}
124
125const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
126
127// A horizontal bar of `cells` at most, to the nearest eighth of a cell.
128export function bar(frac: number, cells: number): string {
129 const total = Math.max(0, Math.min(1, Number.isFinite(frac) ? frac : 0)) * cells
130 const full = Math.floor(total)
131 const rest = Math.round((total - full) * 8)
132 if (rest === 8) return '█'.repeat(full + 1)
133
134 return '█'.repeat(full) + EIGHTHS[rest]
135}
136
137const TICKS = '▁▂▃▄▅▆▇█'
138
139export function spark(values: readonly number[]): string {
140 if (values.length === 0) return ''
141 const lo = Math.min(...values)
142 const hi = Math.max(...values)
143
144 return values
145 .map(v => (hi === lo ? TICKS[3] : TICKS[Math.round(((v - lo) / (hi - lo)) * (TICKS.length - 1))]))
146 .join('')
147}
148
149// Trailing `{tone}` on a line: `build {ok}` → ['build', 'success'].
150export function splitTone(s: string): [string, string | undefined] {
151 const m = /\s*\{([\w#-]+)\}\s*$/.exec(s)
152 if (!m) return [s.trim(), undefined]
153
154 return [s.slice(0, m.index).trim(), tone(m[1])]
155}
156
157export function hexToRgb(hex: string): [number, number, number] {
158 let h = hex.replace('#', '')
159 if (h.length === 3) h = [...h].map(c => c + c).join('')
160 const n = parseInt(h, 16)
161
162 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
163}
164
165export function rgbToHex(rgb: readonly number[]): string {
166 return '#' + rgb.slice(0, 3).map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')
167}
168
169export function mix(a: string, b: string, t: number): string {
170 const x = hexToRgb(a)
171 const y = hexToRgb(b)
172
173 return rgbToHex(x.map((v, i) => v + ((y[i] ?? v) - v) * t))
174}
175
176// A ramp through several stops, `t` in 0..1.
177export function ramp(stops: readonly string[], t: number): string {
178 const c = Math.max(0, Math.min(1, t)) * (stops.length - 1)
179 const i = Math.min(stops.length - 2, Math.floor(c))
180
181 return mix(stops[i] ?? '#000000', stops[i + 1] ?? stops[i] ?? '#000000', c - i)
182}
183
184// Theme keys have no hex value a mod can read, so gradients use these.
185export const HEX_OF: Record<string, string> = {
186 claude: '#d77757',
187 success: '#4eba65',
188 warning: '#ffc107',
189 error: '#ff6b80',
190 suggestion: '#b1b9f9',
191 autoAccept: '#af87ff',
192 planMode: '#48968c',
193 inactive: '#999999',
194}
195
196export function hexOf(color: string | undefined, fallback: string): string {
197 if (!color) return fallback
198 if (HEX.test(color)) return color
199
200 return HEX_OF[tone(color) ?? color] ?? fallback
201}
202
203export const HEAT = ['#22305a', '#2f5d8a', '#2f8f9d', '#5fb88a', '#d8c25a', '#e98b4a', '#d9534f']
204
205// Relative luminance, 0 (black) to 1 (white), to pick text over a fill.
206export function luminance(hex: string): number {
207 const [r, g, b] = hexToRgb(hex).map(v => {
208 const c = v / 255
209
210 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
211 }) as [number, number, number]
212
213 return 0.2126 * r + 0.7152 * g + 0.0722 * b
214}
215hooks/widgets.ts 982 lines1// The block widgets: each takes a tag's body, its attributes and the width it
2// may use, and answers rows of styled runs. A widget that cannot fit answers
3// a plainer form of the same content rather than a broken drawing.
4
5import type { Attrs } from './parse'
6import { rich } from './parse'
7import {
8 bar,
9 clip,
10 HEAT,
11 hexOf,
12 mix,
13 luminance,
14 nums,
15 num,
16 pad,
17 ramp,
18 type Row,
19 type Run,
20 runsWidth,
21 SERIES,
22 splitTone,
23 type Style,
24 tone,
25 width,
26} from './text'
27
28const DIM: Style = { fg: 'inactive' }
29
30function lines(body: string): string[] {
31 return body.split('\n').filter(l => l.trim() && !/^\s*\/\//.test(l))
32}
33
34// At the first separator only, so a detail may hold another one.
35function splitOnce(s: string, sep: RegExp): [string, string | undefined] {
36 const m = sep.exec(s)
37
38 return m ? [s.slice(0, m.index), s.slice(m.index + m[0].length)] : [s, undefined]
39}
40
41function fmt(n: number): string {
42 const a = Math.abs(n)
43 if (a >= 1e9) return `${+(n / 1e9).toFixed(1)}B`
44 if (a >= 1e6) return `${+(n / 1e6).toFixed(1)}M`
45 if (a >= 1e4) return `${+(n / 1e3).toFixed(1)}k`
46 if (a >= 100 || Number.isInteger(n)) return String(Math.round(n))
47
48 return String(+n.toFixed(2))
49}
50
51function title(attrs: Attrs, extra: Run[] = []): Row[] {
52 if (!attrs.title) return []
53
54 return [{ runs: [...rich(attrs.title, { bold: true }), ...extra] }]
55}
56
57// ── canvas: a cell grid whose lines join by direction ───────────────────
58
59const U = 1
60const R = 2
61const D = 4
62const L = 8
63const JOIN: Record<number, string> = {
64 1: '│', 4: '│', 5: '│', 2: '─', 8: '─', 10: '─',
65 6: '╭', 12: '╮', 3: '╰', 9: '╯', 7: '├', 13: '┤', 14: '┬', 11: '┴', 15: '┼',
66}
67
68type Cell = { ch?: string; mask: number; style?: Style }
69
70class Canvas {
71 cells: Cell[][]
72 readonly w: number
73
74 constructor(w: number, h: number) {
75 this.w = w
76 this.cells = Array.from({ length: h }, () => Array.from({ length: w }, () => ({ mask: 0 })))
77 }
78
79 at(x: number, y: number): Cell | undefined {
80 return this.cells[y]?.[x]
81 }
82
83 text(x: number, y: number, s: string, style?: Style) {
84 let cx = x
85 for (const ch of s) {
86 const cell = this.at(cx, y)
87 if (cell) Object.assign(cell, { ch, style })
88 cx += width(ch)
89 // A wide glyph takes the next cell too: blank it so the row stays aligned.
90 if (width(ch) === 2 && this.at(cx - 1, y)) Object.assign(this.at(cx - 1, y)!, { ch: '', style })
91 }
92 }
93
94 join(x: number, y: number, mask: number, style?: Style) {
95 const cell = this.at(x, y)
96 if (!cell || cell.ch !== undefined) return
97 cell.mask |= mask
98 cell.style ??= style
99 }
100
101 vline(x: number, y0: number, y1: number, style?: Style) {
102 const [a, b] = y0 <= y1 ? [y0, y1] : [y1, y0]
103 for (let y = a; y <= b; y++) this.join(x, y, (y > a ? U : 0) | (y < b ? D : 0) || U | D, style)
104 }
105
106 hline(y: number, x0: number, x1: number, style?: Style) {
107 const [a, b] = x0 <= x1 ? [x0, x1] : [x1, x0]
108 for (let x = a; x <= b; x++) this.join(x, y, (x > a ? L : 0) | (x < b ? R : 0) || L | R, style)
109 }
110
111 box(x: number, y: number, w: number, label: string, style: Style, labelStyle: Style) {
112 this.text(x, y, '╭' + '─'.repeat(w - 2) + '╮', style)
113 this.text(x, y + 1, '│', style)
114 this.text(x + 1, y + 1, pad(' ' + label, w - 2), labelStyle)
115 this.text(x + w - 1, y + 1, '│', style)
116 this.text(x, y + 2, '╰' + '─'.repeat(w - 2) + '╯', style)
117 }
118
119 rows(): Row[] {
120 const out = this.cells.map(row => {
121 const runs: Run[] = []
122 for (const cell of row) {
123 const ch = cell.ch ?? JOIN[cell.mask] ?? ' '
124 const style = ch === ' ' ? undefined : cell.style
125 const last = runs[runs.length - 1]
126 if (last && sameStyle(last, style)) last.text += ch
127 else runs.push({ ...style, text: ch })
128 }
129 const tail = runs[runs.length - 1]
130 if (tail) tail.text = tail.text.trimEnd()
131
132 return { runs: runs.filter(r => r.text) }
133 })
134 while (out.length > 0 && out[out.length - 1]!.runs.length === 0) out.pop()
135
136 return out
137 }
138}
139
140function sameStyle(a: Style, b: Style | undefined): boolean {
141 const keys = ['fg', 'bg', 'bold', 'italic', 'underline', 'strike', 'dim', 'inverse'] as const
142
143 return keys.every(k => a[k] === b?.[k])
144}
145
146// ── flow: a layered graph of boxes ──────────────────────────────────────
147
148type FlowNode = { id: string; label: string; tone?: string; layer: number; order: number; w: number; x: number; isDummy: boolean }
149
150// The most edges a flow keeps: a line of fans (`a, b, … -> x, y, …`) makes
151// the product of its sides, so a short body could ask for millions.
152const FLOW_KEPT_EDGES = 2000
153
154function parseFlow(body: string) {
155 const nodes = new Map<string, { label: string; tone?: string }>()
156 const edges: Array<[string, string]> = []
157 const seen = new Set<string>()
158 let isCut = false
159 const edge = (a: string, b: string) => {
160 const key = a + '\u0000' + b
161 if (a === b || seen.has(key)) return
162 if (edges.length === FLOW_KEPT_EDGES) {
163 isCut = true
164
165 return
166 }
167 seen.add(key)
168 edges.push([a, b])
169 }
170 const node = (raw: string) => {
171 const [label, t] = splitTone(raw)
172 if (!label) return undefined
173 const known = nodes.get(label)
174 if (!known) nodes.set(label, { label, tone: t })
175 else if (t) known.tone = t
176
177 return label
178 }
179 for (const line of lines(body)) {
180 const steps = line.split(/\s*(?:-->|->|→|=>)\s*/).map(seg =>
181 seg
182 .split(/\s*,\s*/)
183 .map(node)
184 .filter((id): id is string => id !== undefined),
185 )
186 for (let i = 0; i + 1 < steps.length && !isCut; i++) for (const a of steps[i]!) for (const b of steps[i + 1]!) edge(a, b)
187 }
188
189 return { nodes, edges, isCut }
190}
191
192// Each node's edges, in the order given.
193function adjacency<T>(edges: ReadonlyArray<readonly [T, T]>, isReversed = false): Map<T, T[]> {
194 const out = new Map<T, T[]>()
195 for (const [a, b] of edges) {
196 const [from, to] = isReversed ? [b, a] : [a, b]
197 const list = out.get(from)
198 if (list) list.push(to)
199 else out.set(from, [to])
200 }
201
202 return out
203}
204
205// Past these a graph is listed, not laid out: it could not fit the width,
206// and its layout costs time with the square of its size.
207const FLOW_MAX_NODES = 100
208const FLOW_MAX_EDGES = 300
209
210export function flow(body: string, attrs: Attrs, avail: number): Row[] {
211 const { nodes, edges, isCut } = parseFlow(body)
212 if (nodes.size === 0) return []
213 const ids = [...nodes.keys()]
214 if (ids.length > FLOW_MAX_NODES || edges.length > FLOW_MAX_EDGES) {
215 const more: Row[] = isCut ? [{ runs: [{ ...DIM, text: `… edges past the first ${FLOW_KEPT_EDGES} not listed` }] }] : []
216
217 return [...title(attrs), ...flowList(ids, adjacency(edges), nodes), ...more]
218 }
219
220 // Back edges (cycles) are kept out of the layout and listed under it.
221 const back: Array<[string, string]> = []
222 const forward: Array<[string, string]> = []
223 {
224 const state = new Map<string, 'open' | 'done'>()
225 const outOf = adjacency(edges)
226 const visit = (v: string) => {
227 state.set(v, 'open')
228 for (const to of outOf.get(v) ?? []) {
229 const e: [string, string] = [v, to]
230 const s = state.get(e[1])
231 if (s === 'open') back.push(e)
232 else {
233 forward.push(e)
234 if (!s) visit(e[1])
235 }
236 }
237 state.set(v, 'done')
238 }
239 for (const v of ids) if (!state.has(v)) visit(v)
240 }
241
242 const succOf = adjacency(forward)
243 const predOf = adjacency(forward, true)
244 const succ = (v: string) => succOf.get(v) ?? []
245 const pred = (v: string) => predOf.get(v) ?? []
246
247 const isPath =
248 back.length === 0 &&
249 forward.length === ids.length - 1 &&
250 ids.every(v => succ(v).length <= 1 && pred(v).length <= 1)
251 if (isPath) {
252 const start = ids.find(v => pred(v).length === 0)!
253 const chain = [start]
254 for (let next = succ(start)[0]; next !== undefined; next = succ(next)[0]) chain.push(next)
255 const drawn = chainRow(chain.map(id => nodes.get(id)!), avail, attrs.dir !== 'down')
256 if (drawn) return [...title(attrs), ...drawn]
257 }
258
259 // Longest-path layering over the acyclic edges.
260 const layer = new Map<string, number>()
261 const depth = (v: string, seen = new Set<string>()): number => {
262 if (layer.has(v)) return layer.get(v)!
263 if (seen.has(v)) return 0
264 seen.add(v)
265 const d = Math.max(-1, ...pred(v).map(p => depth(p, seen))) + 1
266 layer.set(v, d)
267
268 return d
269 }
270 ids.forEach(v => depth(v))
271
272 // Edges that skip layers run through one-cell dummies, a layer at a time.
273 const all = new Map<string, FlowNode>()
274 for (const id of ids) {
275 const n = nodes.get(id)!
276 all.set(id, { id, label: n.label, tone: n.tone, layer: layer.get(id)!, order: 0, w: width(n.label) + 4, x: 0, isDummy: false })
277 }
278 const links: Array<[string, string]> = []
279 let dummies = 0
280 for (const [a, b] of forward) {
281 let from = a
282 for (let l = layer.get(a)! + 1; l < layer.get(b)!; l++) {
283 const id = `\u0000${dummies++}`
284 all.set(id, { id, label: '', layer: l, order: 0, w: 1, x: 0, isDummy: true })
285 links.push([from, id])
286 from = id
287 }
288 links.push([from, b])
289 }
290
291 const depthCount = Math.max(...[...all.values()].map(n => n.layer)) + 1
292 const layers: FlowNode[][] = Array.from({ length: depthCount }, () => [])
293 for (const n of all.values()) layers[n.layer]!.push(n)
294 layers.forEach(ls => ls.forEach((n, i) => (n.order = i)))
295
296 const upOf = adjacency(links, true)
297 const downOf = adjacency(links)
298 const up = (n: FlowNode) => (upOf.get(n.id) ?? []).map(id => all.get(id)!)
299 const down = (n: FlowNode) => (downOf.get(n.id) ?? []).map(id => all.get(id)!)
300 const mean = (xs: number[], dflt: number) => (xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : dflt)
301 // The middle parent (or child) so a node sits straight under one of them.
302 const median = (xs: number[], dflt: number) => {
303 if (xs.length === 0) return dflt
304 const sorted = [...xs].sort((a, b) => a - b)
305
306 return sorted[Math.floor((sorted.length - 1) / 2)] ?? dflt
307 }
308
309 // Barycentre sweeps to cut crossings.
310 for (let pass = 0; pass < 4; pass++) {
311 const range = pass % 2 === 0 ? layers.slice(1) : layers.slice(0, -1).reverse()
312 for (const ls of range) {
313 const key = new Map(ls.map(n => [n, mean((pass % 2 === 0 ? up(n) : down(n)).map(m => m.order), n.order)]))
314 ls.sort((a, b) => key.get(a)! - key.get(b)! || a.order - b.order)
315 ls.forEach((n, i) => (n.order = i))
316 }
317 }
318
319 const centre = (n: FlowNode) => n.x + Math.floor(n.w / 2)
320 const place = (gap: number) => {
321 const placeLayer = (ls: FlowNode[], toward: (n: FlowNode) => FlowNode[]) => {
322 let right = -gap
323 for (const n of ls) {
324 const want = median(toward(n).map(centre), centre(n)) - Math.floor(n.w / 2)
325 n.x = Math.max(Math.round(want), right + gap, 0)
326 right = n.x + n.w
327 }
328 }
329 let x = 0
330 for (const n of layers[0]!) {
331 n.x = x
332 x += n.w + gap
333 }
334 for (const ls of layers.slice(1)) placeLayer(ls, up)
335 for (const ls of layers.slice(0, -1).reverse()) placeLayer(ls, down)
336 for (const ls of layers.slice(1)) placeLayer(ls, up)
337
338 return Math.max(...[...all.values()].map(n => n.x + n.w))
339 }
340 let gap = 3
341 let total = place(gap)
342 while (total > avail && gap > 1) total = place(--gap)
343 if (total > avail) return [...title(attrs), ...flowList(ids, adjacency([...forward, ...back]), nodes)]
344
345 // Channels between layers: one track per source whose edges run sideways.
346 type Span = { src: FlowNode; lo: number; hi: number; targets: Set<number>; track: number }
347 const channels = layers.slice(0, -1).map(ls => {
348 const spans: Span[] = []
349 for (const src of ls) {
350 const xs = down(src).map(centre)
351 if (xs.length === 0) continue
352 const lo = Math.min(centre(src), ...xs)
353 const hi = Math.max(centre(src), ...xs)
354 if (lo !== hi) spans.push({ src, lo, hi, targets: new Set(xs), track: -1 })
355 }
356 spans.sort((a, b) => a.lo - b.lo)
357 const tracks: Span[][] = []
358 for (const s of spans) {
359 const clash = (t: Span) =>
360 s.lo <= t.hi && t.lo <= s.hi && !((s.hi === t.lo || t.hi === s.lo) && [...s.targets].some(x => t.targets.has(x) && (x === s.hi || x === s.lo)))
361 let i = tracks.findIndex(tr => !tr.some(clash))
362 if (i === -1) i = tracks.push([]) - 1
363 tracks[i]!.push(s)
364 s.track = i
365 }
366
367 return { spans, height: tracks.length + 2 }
368 })
369
370 const tops: number[] = []
371 let y = 0
372 layers.forEach((_, i) => {
373 tops.push(y)
374 y += 3 + (channels[i]?.height ?? 0)
375 })
376 const canvas = new Canvas(total, y)
377 const edge: Style = DIM
378
379 layers.forEach((ls, i) => {
380 const top = tops[i]!
381 for (const n of ls) {
382 if (n.isDummy) canvas.vline(n.x, top, top + 2, edge)
383 else {
384 const color = n.tone
385 canvas.box(n.x, top, n.w, n.label, { fg: color ?? 'inactive' }, { fg: color, bold: true })
386 }
387 }
388 const ch = channels[i]
389 if (!ch) return
390 const stub = top + 3
391 const arrow = stub + ch.height - 1
392 for (const src of ls) {
393 const px = centre(src)
394 const kids = down(src)
395 if (kids.length === 0) continue
396 // In the edge's color, not the box's, so a toned box doesn't tint its shaft.
397 if (!src.isDummy) canvas.text(px, top + 2, '┬', edge)
398 const span = ch.spans.find(s => s.src === src)
399 const row = span ? stub + 1 + span.track : arrow
400 canvas.vline(px, stub, row, edge)
401 if (span) canvas.hline(row, span.lo, span.hi, edge)
402 for (const kid of kids) {
403 const cx = centre(kid)
404 canvas.vline(cx, row, arrow, edge)
405 if (!kid.isDummy) canvas.text(cx, arrow, '▼', edge)
406 }
407 }
408 })
409
410 const notes: Row[] = back.map(([a, b]) => ({ runs: [{ ...DIM, text: `cycle: ${a} → ${b}` }] }))
411
412 return [...title(attrs), ...canvas.rows(), ...notes]
413}
414
415function chainRow(chain: Array<{ label: string; tone?: string }>, avail: number, isAllowed: boolean): Row[] | undefined {
416 const arrow = ' ──► '
417 const total = chain.reduce((s, n) => s + width(n.label) + 4, 0) + (chain.length - 1) * width(arrow)
418 if (!isAllowed || total > avail) return undefined
419 const canvas = new Canvas(total, 3)
420 let x = 0
421 chain.forEach((n, i) => {
422 const w = width(n.label) + 4
423 canvas.box(x, 0, w, n.label, { fg: n.tone ?? 'inactive' }, { fg: n.tone, bold: true })
424 x += w
425 if (i < chain.length - 1) {
426 canvas.text(x, 1, arrow, DIM)
427 x += width(arrow)
428 }
429 })
430
431 return canvas.rows()
432}
433
434function flowList(ids: string[], outOf: Map<string, string[]>, nodes: Map<string, { tone?: string }>): Row[] {
435 return ids
436 .map((id): Row | undefined => {
437 const to = outOf.get(id) ?? []
438 if (to.length === 0) return undefined
439 const runs: Run[] = [{ fg: nodes.get(id)?.tone, bold: true, text: id }, { ...DIM, text: ' → ' }]
440 to.forEach((b, i) => {
441 if (i) runs.push({ ...DIM, text: ', ' })
442 runs.push({ fg: nodes.get(b)?.tone, text: b })
443 })
444
445 return { runs, wrap: true }
446 })
447 .filter((r): r is Row => r !== undefined)
448}
449
450// ── seq: a sequence diagram ─────────────────────────────────────────────
451
452type SeqItem =
453 | { kind: 'msg'; from: number; to: number; label: string; isReply: boolean; tone?: string }
454 | { kind: 'divider'; label: string }
455
456export function seq(body: string, attrs: Attrs, avail: number): Row[] {
457 const names: string[] = []
458 const who = (s: string) => {
459 const name = s.trim()
460 let i = names.indexOf(name)
461 if (i === -1) i = names.push(name) - 1
462
463 return i
464 }
465 const items: SeqItem[] = []
466 for (const line of lines(body)) {
467 const divider = /^\s*={2,}\s*(.*?)\s*={2,}\s*$/.exec(line)
468 if (divider) {
469 items.push({ kind: 'divider', label: divider[1] ?? '' })
470 continue
471 }
472 const m = /^(.+?)\s*(-->|->|→|<--|<-|←)\s*(.+?)\s*(?::\s*(.*))?$/.exec(line) as [string, string, string, string, string?] | null
473 if (!m) continue
474 const [label, t] = splitTone(m[4] ?? '')
475 const isBackward = m[2].startsWith('<') || m[2] === '←'
476 const a = who(m[1])
477 const b = who(m[3])
478 items.push({
479 kind: 'msg',
480 from: isBackward ? b : a,
481 to: isBackward ? a : b,
482 label,
483 isReply: m[2].includes('--'),
484 tone: t,
485 })
486 }
487 if (names.length === 0) return []
488
489 // Columns far enough apart for every label drawn between them.
490 const hw = names.map(n => width(n) + 4)
491 const W = (i: number) => hw[i] ?? 0
492 const X = (i: number) => xs[i] ?? 0
493 const xs: number[] = [Math.floor(W(0) / 2)]
494 for (let j = 1; j < names.length; j++) {
495 let x = X(j - 1) + Math.ceil(W(j - 1) / 2) + Math.floor(W(j) / 2) + 2
496 for (const it of items) {
497 if (it.kind !== 'msg') continue
498 const lo = Math.min(it.from, it.to)
499 const hi = Math.max(it.from, it.to)
500 if (hi === j && lo < j) x = Math.max(x, X(lo) + width(it.label) + 4)
501 if (it.from === it.to && it.from === j - 1) x = Math.max(x, X(j - 1) + width(it.label) + 7)
502 }
503 xs.push(x)
504 }
505 const last = names.length - 1
506 let total = X(last) + Math.ceil(W(last) / 2)
507 for (const it of items) if (it.kind === 'msg' && it.from === it.to && it.from === last) total = Math.max(total, X(last) + 5 + width(it.label))
508 if (total > avail) return [...title(attrs), ...seqList(names, items)]
509
510 const height = 3 + items.reduce((h, it) => h + (it.kind === 'divider' ? 1 : 2), 0) + 1
511 const canvas = new Canvas(total, height)
512 names.forEach((n, i) => {
513 canvas.box(X(i) - Math.floor(W(i) / 2), 0, W(i), n, { fg: 'inactive' }, { bold: true })
514 canvas.text(X(i), 2, '┬', { fg: 'inactive' })
515 })
516
517 let y = 3
518 for (const it of items) {
519 if (it.kind === 'divider') {
520 const text = it.label ? ` ${it.label} ` : ''
521 const start = Math.max(0, Math.floor((total - width(text)) / 2))
522 canvas.text(0, y, '┈'.repeat(total), DIM)
523 canvas.text(start, y, text, { fg: 'inactive', italic: true })
524 y += 1
525 continue
526 }
527 const color = it.tone ?? (it.isReply ? 'inactive' : 'suggestion')
528 const style: Style = { fg: color }
529 const a = X(it.from)
530 const b = X(it.to)
531 if (it.from === it.to) {
532 canvas.hline(y, a, a + 2, style)
533 canvas.vline(a + 2, y, y + 1, style)
534 canvas.text(a + 4, y, it.label, { fg: it.tone })
535 canvas.hline(y + 1, a + 1, a + 2, style)
536 canvas.text(a + 1, y + 1, '◄', style)
537 } else {
538 const lo = Math.min(a, b)
539 const hi = Math.max(a, b)
540 canvas.text(lo + 1 + Math.max(0, Math.floor((hi - lo - 1 - width(it.label)) / 2)), y, it.label, { fg: it.tone, italic: it.isReply })
541 // Stops short of the target's lifeline, so only the source joins it.
542 canvas.hline(y + 1, a, b < a ? b + 1 : b - 1, style)
543 if (it.isReply)
544 for (let x = lo + 1; x < hi; x++) if (!xs.includes(x)) canvas.text(x, y + 1, '╌', style)
545 canvas.text(b < a ? b + 1 : b - 1, y + 1, b < a ? '◄' : '►', style)
546 }
547 y += 2
548 }
549 // Lifelines last: they join what crosses them and fill every gap.
550 xs.forEach(x => canvas.vline(x, 3, height - 1, DIM))
551
552 return [...title(attrs), ...canvas.rows()]
553}
554
555function seqList(names: string[], items: SeqItem[]): Row[] {
556 return items.map((it): Row =>
557 it.kind === 'divider'
558 ? { runs: [{ ...DIM, italic: true, text: `── ${it.label} ──` }] }
559 : {
560 wrap: true,
561 runs: [
562 { bold: true, text: names[it.from] ?? '' },
563 { fg: it.tone ?? (it.isReply ? 'inactive' : 'suggestion'), text: it.isReply ? ' --> ' : ' → ' },
564 { bold: true, text: names[it.to] ?? '' },
565 ...(it.label ? [{ ...DIM, text: ': ' }, { fg: it.tone, text: it.label }] : []),
566 ],
567 },
568 )
569}
570
571// ── bars: a horizontal bar chart, optionally before → after ────────────
572
573export function bars(body: string, attrs: Attrs, avail: number): Row[] {
574 type Item = { label: string; a: number; b?: number; shown: string; tone?: string }
575 const items: Item[] = []
576 for (const line of lines(body)) {
577 const m = /^(.*?)\s*[:|]\s*([^:|]*)$/.exec(line) as [string, string, string] | null
578 if (!m) continue
579 const [rawValue, t] = splitTone(m[2])
580 const [before, after] = rawValue.split(/\s*(?:->|→)\s*/)
581 const a = num(before ?? '')
582 if (a === undefined) continue
583 const b = after === undefined ? undefined : num(after)
584 items.push({ label: m[1].trim(), a, b, shown: rawValue, tone: t })
585 }
586 if (items.length === 0) return []
587
588 const better = attrs.better
589 const unit = attrs.unit ?? ''
590 const max = Number(attrs.max) || Math.max(...items.flatMap(i => [i.a, i.b ?? 0]))
591 const labelW = Math.min(24, Math.max(...items.map(i => width(i.label))))
592 const shown = items.map(i => {
593 if (i.b === undefined) return [{ text: i.shown + unit }] as Run[]
594 const delta = i.a === 0 ? 0 : ((i.b - i.a) / Math.abs(i.a)) * 100
595 const good = better === 'lower' ? delta < 0 : better === 'higher' ? delta > 0 : undefined
596 const sign = delta > 0 ? '+' : delta < 0 ? '−' : '±'
597
598 return [
599 { ...DIM, text: `${fmt(i.a)}${unit} → ` },
600 { bold: true, text: `${fmt(i.b)}${unit}` },
601 { fg: good === undefined || delta === 0 ? 'inactive' : good ? 'success' : 'error', text: ` ${sign}${Math.abs(Math.round(delta))}%` },
602 ] as Run[]
603 })
604 const valueW = Math.max(...shown.map(runsWidth))
605 const cells = Math.max(6, Math.min(48, avail - labelW - valueW - 3))
606 const base = tone(attrs.tone) ?? 'claude'
607
608 const rows: Row[] = items.map((it, i) => {
609 const color = it.tone ?? (items.length > 1 && 'series' in attrs ? SERIES[i % SERIES.length] : base)
610 const head: Run = { text: pad(clip(it.label, labelW), labelW) + ' ' }
611 if (it.b === undefined) {
612 const fill = bar(max ? it.a / max : 0, cells)
613
614 return { runs: [head, { fg: color, text: fill }, { text: ' '.repeat(cells - width(fill) + 1) }, ...shown[i]!] }
615 }
616 // Before and after on one row: the after bar solid, the before as a
617 // faint trace past it when it was longer. With a trace, the solid bar
618 // keeps to whole cells: an eighth block would leave a gap before it.
619 const nowFrac = max ? it.b / max : 0
620 const wasCells = Math.round((max ? it.a / max : 0) * cells)
621 const solid = Math.max(it.b > 0 ? 1 : 0, Math.round(nowFrac * cells))
622 const hasTrace = wasCells > solid
623 const now = hasTrace ? '█'.repeat(solid) : bar(nowFrac, cells)
624 const trace = hasTrace ? wasCells - solid : 0
625
626 return {
627 runs: [
628 head,
629 { fg: color, text: now },
630 { ...DIM, text: '░'.repeat(trace) },
631 { text: ' '.repeat(Math.max(0, cells - width(now) - trace) + 1) },
632 ...shown[i]!,
633 ],
634 }
635 })
636
637 return [...title(attrs), ...rows]
638}
639
640// ── chart: a braille line chart ─────────────────────────────────────────
641
642const BRAILLE = [
643 [0x01, 0x08],
644 [0x02, 0x10],
645 [0x04, 0x20],
646 [0x40, 0x80],
647]
648
649export function chart(body: string, attrs: Attrs, avail: number): Row[] {
650 const series: Array<{ name: string; values: number[]; tone?: string }> = []
651 const loose: number[] = []
652 if (attrs.values) loose.push(...nums(attrs.values))
653 for (const line of lines(body)) {
654 const m = /^\s*([^:\d-][^:]*):\s*(.+)$/.exec(line) as [string, string, string] | null
655 if (m) {
656 const [values, t] = splitTone(m[2])
657 series.push({ name: m[1].trim(), values: nums(values), tone: t })
658 } else loose.push(...nums(line))
659 }
660 if (loose.length) series.unshift({ name: '', values: loose })
661 const live = series.filter(s => s.values.length > 0)
662 if (live.length === 0) return []
663
664 const every = live.flatMap(s => s.values)
665 const lo = attrs.min !== undefined ? Number(attrs.min) : 'zero' in attrs ? Math.min(0, ...every) : Math.min(...every)
666 const hi = attrs.max !== undefined ? Number(attrs.max) : Math.max(...every)
667 const span = hi - lo || 1
668 const rowsN = Math.max(2, Math.min(24, Number(attrs.height) || 8))
669 const unit = attrs.unit ?? ''
670 const labels = [fmt(hi) + unit, fmt((hi + lo) / 2) + unit, fmt(lo) + unit]
671 const labelW = Math.max(...labels.map(width))
672 const cols = Math.max(8, Math.min(Number(attrs.width) || 64, avail - labelW - 2))
673 const dotsW = cols * 2
674 const dotsH = rowsN * 4
675
676 const grid = Array.from({ length: rowsN }, () => Array.from({ length: cols }, () => ({ bits: 0, owner: -1, count: 0 })))
677 const dot = (x: number, y: number, s: number) => {
678 const cell = grid[Math.floor(y / 4)]?.[Math.floor(x / 2)]
679 if (!cell) return
680 const bit = BRAILLE[y % 4]![x % 2]!
681 if (!(cell.bits & bit)) {
682 cell.bits |= bit
683 if (cell.owner === -1 || s < cell.owner) cell.owner = s
684 }
685 }
686 live.forEach((s, si) => {
687 const pts = s.values.map((v, i): [number, number] => [
688 s.values.length === 1 ? 0 : Math.round((i * (dotsW - 1)) / (s.values.length - 1)),
689 dotsH - 1 - Math.round(((Math.max(lo, Math.min(hi, v)) - lo) / span) * (dotsH - 1)),
690 ])
691 pts.forEach(([x, y], i) => {
692 dot(x, y, si)
693 const next = pts[i + 1]
694 if (!next) return
695 const steps = Math.max(Math.abs(next[0] - x), Math.abs(next[1] - y))
696 for (let k = 1; k < steps; k++) dot(Math.round(x + ((next[0] - x) * k) / steps), Math.round(y + ((next[1] - y) * k) / steps), si)
697 })
698 })
699
700 const colorOf = (si: number) => live[si]?.tone ?? (live.length === 1 ? tone(attrs.tone) ?? 'claude' : SERIES[si % SERIES.length])
701 const rows: Row[] = grid.map((row, r) => {
702 const label = r === 0 ? labels[0] : r === rowsN - 1 ? labels[2] : rowsN >= 5 && r === Math.floor((rowsN - 1) / 2) ? labels[1] : ''
703 const runs: Run[] = [{ ...DIM, text: pad(label ?? '', labelW, 'right') + (label ? ' ┤' : ' │') }]
704 for (const cell of row) {
705 const ch = String.fromCharCode(0x2800 + cell.bits)
706 const fg = cell.bits ? colorOf(cell.owner) : undefined
707 const prev = runs[runs.length - 1]!
708 if (prev.fg === fg && runs.length > 1) prev.text += ch
709 else runs.push({ fg, text: ch })
710 }
711
712 return { runs }
713 })
714 rows.push({ runs: [{ ...DIM, text: ' '.repeat(labelW + 1) + '└' + '─'.repeat(cols) }] })
715
716 const xLabels = (attrs.x ?? '').split(',').map(s => s.trim()).filter(Boolean)
717 if (xLabels.length) {
718 const line = Array.from({ length: cols }, () => ' ')
719 let free = 0
720 xLabels.forEach((t, i) => {
721 const at = xLabels.length === 1 ? 0 : Math.round((i * (cols - 1)) / (xLabels.length - 1))
722 const start = Math.min(cols - width(t), i === 0 ? 0 : Math.max(0, at - Math.floor(width(t) / 2)))
723 if (start < free) return
724 ;[...t].forEach((ch, k) => (line[start + k] = ch))
725 free = start + width(t) + 1
726 })
727 rows.push({ runs: [{ ...DIM, text: ' '.repeat(labelW + 2) + line.join('').trimEnd() }] })
728 }
729 if (live.length > 1 || live[0]!.name) {
730 const legend: Run[] = [{ text: ' '.repeat(labelW + 2) }]
731 live.forEach((s, i) => {
732 if (i) legend.push({ text: ' ' })
733 legend.push({ fg: colorOf(i), text: '━━ ' }, { text: s.name || 'series' })
734 })
735 rows.push({ runs: legend, wrap: true })
736 }
737
738 return [...title(attrs), ...rows]
739}
740
741// ── heat: a heat map ────────────────────────────────────────────────────
742
743export function heat(body: string, attrs: Attrs, avail: number): Row[] {
744 const data: Array<{ label: string; values: number[] }> = []
745 for (const line of lines(body)) {
746 const m = /^\s*(.*?)\s*[:|]\s*(.+)$/.exec(line) as [string, string, string] | null
747 if (m) data.push({ label: m[1], values: nums(m[2]) })
748 else data.push({ label: '', values: nums(line) })
749 }
750 if (data.length === 0) return []
751
752 const header = (attrs.cols ?? attrs.x ?? '').split(',').map(s => s.trim()).filter(Boolean)
753 const every = data.flatMap(d => d.values)
754 const lo = attrs.min !== undefined ? Number(attrs.min) : Math.min(...every)
755 const hi = attrs.max !== undefined ? Number(attrs.max) : Math.max(...every)
756 const n = Math.max(...data.map(d => d.values.length))
757 const showValues = 'values' in attrs
758 const labelW = Math.min(20, Math.max(0, ...data.map(d => width(d.label))))
759 let cellW = Math.max(2, Math.min(5, ...[Math.max(0, ...header.map(width))].map(w => Math.max(w, 2))))
760 if (showValues) cellW = Math.max(cellW, ...every.map(v => width(fmt(v)) + 1))
761 while (cellW > 1 && labelW + 1 + cellW * n > avail) cellW -= 1
762 const stops = attrs.colors ? attrs.colors.split(',').map(s => s.trim()) : HEAT
763
764 const rows: Row[] = []
765 if (header.length) {
766 rows.push({ runs: [{ ...DIM, text: ' '.repeat(labelW + 1) + header.map(h => pad(clip(h, cellW), cellW, 'center')).join('') }] })
767 }
768 for (const d of data) {
769 const runs: Run[] = [{ text: pad(clip(d.label, labelW), labelW) + ' ' }]
770 for (let i = 0; i < n; i++) {
771 const v = d.values[i]
772 if (v === undefined) {
773 runs.push({ text: ' '.repeat(cellW) })
774 continue
775 }
776 const bg = ramp(stops, hi === lo ? 0.5 : (v - lo) / (hi - lo))
777 const text = showValues ? pad(fmt(v), cellW, 'center') : ' '.repeat(cellW)
778 runs.push({ bg, fg: luminance(bg) > 0.5 ? '#111111' : '#f5f5f5', text })
779 }
780 rows.push({ runs })
781 }
782 const legend: Run[] = [{ text: ' '.repeat(labelW + 1) }, { ...DIM, text: fmt(lo) + ' ' }]
783 for (let i = 0; i < 8; i++) legend.push({ bg: ramp(stops, i / 7), text: ' ' })
784 legend.push({ ...DIM, text: ' ' + fmt(hi) + (attrs.unit ?? '') })
785 rows.push({ runs: legend })
786
787 return [...title(attrs), ...rows]
788}
789
790// ── tree ────────────────────────────────────────────────────────────────
791
792// Deeper lines draw at this depth: its rails already fill 128 cells.
793const TREE_MAX_DEPTH = 32
794
795export function tree(body: string, attrs: Attrs): Row[] {
796 const raw = body.split('\n').filter(l => l.trim())
797 const indents = raw.map(l => /^[ \t]*/.exec(l)![0].replace(/\t/g, ' ').length)
798 const steps = indents.filter(i => i > 0)
799 const unit = steps.length ? Math.min(...steps) : 2
800 const items = raw.map((l, i) => ({ depth: Math.min(TREE_MAX_DEPTH, Math.round(indents[i]! / unit)), text: l.trim() }))
801
802 // Whether a later sibling follows each item: read from the bottom up,
803 // `below[d]` says a line at depth d has come since the last shallower one.
804 const hasNext: boolean[] = []
805 const below: boolean[] = []
806 for (let i = items.length - 1; i >= 0; i--) {
807 const d = items[i]!.depth
808 hasNext[i] = below[d] === true
809 below.length = d
810 below[d] = true
811 }
812
813 // From the top down, `open[d]` says the nearest line at depth d or
814 // shallower is at d and has a later sibling: its rail runs on down.
815 const open: boolean[] = []
816 const rows: Row[] = items.map((it, i) => {
817 const prefix: string[] = []
818 for (let d = 1; d < it.depth; d++) prefix.push(open[d] ? '│ ' : ' ')
819 if (it.depth > 0) prefix.push(hasNext[i] ? '├── ' : '└── ')
820 open.length = it.depth
821 open[it.depth] = hasNext[i]!
822
823 let text = it.text
824 let mark: Style = {}
825 const sigil = /^([+~!-])\s+/.exec(text) as [string, '+' | '~' | '!' | '-'] | null
826 if (sigil) {
827 mark = { fg: { '+': 'success', '-': 'error', '~': 'warning', '!': 'error' }[sigil[1]] }
828 text = text.slice(sigil[0].length)
829 }
830 // A tone may close the line or the name before its note.
831 const [line, lineTone] = splitTone(text)
832 const [main, comment] = splitOnce(line, /\s+(?:#|—|--)\s+/)
833 const [name, nameTone] = splitTone(main)
834 const t = nameTone ?? lineTone
835 const isDir = name.endsWith('/')
836 const style: Style = { ...mark, fg: t ?? mark.fg ?? (isDir ? 'suggestion' : undefined), bold: isDir || it.depth === 0 }
837 const runs: Run[] = [{ ...DIM, text: prefix.join('') }]
838 if (sigil) runs.push({ ...mark, text: sigil[1] + ' ' })
839 runs.push(...rich(name, style))
840 if (comment) runs.push({ text: ' ' }, ...rich(comment, DIM))
841
842 return { runs }
843 })
844
845 return [...title(attrs), ...rows]
846}
847
848// ── steps: a checklist with states ──────────────────────────────────────
849
850// Marks every terminal font draws as text: ✔ is a color emoji in Windows
851// Terminal and Ghostty, and conhost has no ◐ or ⊘.
852const STEP: Record<string, { glyph: string; style: Style; text?: Style }> = {
853 x: { glyph: '✓', style: { fg: 'success' } },
854 ' ': { glyph: '○', style: DIM },
855 '~': { glyph: '●', style: { fg: 'claude', bold: true }, text: { bold: true } },
856 '>': { glyph: '▸', style: { fg: 'claude', bold: true }, text: { bold: true } },
857 '!': { glyph: '✘', style: { fg: 'error', bold: true } },
858 '-': { glyph: '–', style: DIM, text: { fg: 'inactive', strike: true } },
859 '?': { glyph: '?', style: { fg: 'warning', bold: true } },
860}
861
862export function steps(body: string, attrs: Attrs): Row[] {
863 const items = lines(body).map(l => {
864 const m = /^\s*(?:[-*]\s+)?\[([ xX~>!?-])\]\s*(.*)$/.exec(l) as [string, string, string] | null
865
866 return m ? { state: m[1].toLowerCase(), text: m[2] } : { state: ' ', text: l.trim() }
867 })
868 const done = items.filter(i => i.state === 'x').length
869 const rows: Row[] = items.map(({ state, text }) => {
870 const s = STEP[state] ?? STEP[' ']!
871 const [line, t] = splitTone(text)
872 const [main, detail] = splitOnce(line, /\s+(?:—|--)\s+/)
873
874 return {
875 lead: [{ ...s.style, text: s.glyph + ' ' }],
876 runs: [...rich(main, { ...s.text, ...(t ? { fg: t } : {}) }), ...(detail ? [{ text: ' ' }, ...rich(detail, DIM)] : [])],
877 wrap: true,
878 }
879 })
880
881 return [...title(attrs, [{ ...DIM, text: ` ${done}/${items.length}` }]), ...rows]
882}
883
884// ── timeline ────────────────────────────────────────────────────────────
885
886export function timeline(body: string, attrs: Attrs): Row[] {
887 const items = lines(body).map(l => {
888 const m = (/^\s*(.+?)\s+(?:\||—|--)\s+(.*)$/.exec(l) ?? /^\s*([^:]+?):\s+(.*)$/.exec(l)) as [string, string, string] | null
889 const [what, t] = splitTone(m ? m[2] : l)
890
891 return { when: m ? m[1].trim() : '', what, tone: t }
892 })
893 const whenW = Math.min(20, Math.max(0, ...items.map(i => width(i.when))))
894 const rows: Row[] = []
895 items.forEach((it, i) => {
896 rows.push({
897 lead: [{ fg: it.tone ?? 'claude', text: '● ' }, { ...DIM, text: pad(clip(it.when, whenW), whenW) + ' ' }],
898 runs: rich(it.what, { fg: it.tone }),
899 wrap: true,
900 })
901 if (i < items.length - 1) rows.push({ runs: [{ ...DIM, text: '│' }] })
902 })
903
904 return [...title(attrs), ...rows]
905}
906
907// ── kv: aligned keys and values ─────────────────────────────────────────
908
909export function kv(body: string, attrs: Attrs): Row[] {
910 const items = lines(body).map(l => {
911 const m = (/^\s*([^:=]+?)\s*[:=]\s+(.*)$/.exec(l) ?? /^\s*([^:=]+?)\s*[:=](.*)$/.exec(l)) as [string, string, string] | null
912
913 return m ? { key: m[1], value: m[2] } : { key: '', value: l.trim() }
914 })
915 const keyW = Math.min(24, Math.max(0, ...items.map(i => width(i.key))))
916 const keyStyle: Style = { fg: tone(attrs.tone) ?? 'inactive' }
917
918 return [
919 ...title(attrs),
920 ...items.map(it => ({ lead: [{ ...keyStyle, text: pad(clip(it.key, keyW), keyW) + ' ' }], runs: rich(it.value), wrap: true })),
921 ]
922}
923
924// ── rule and banner ─────────────────────────────────────────────────────
925
926export function rule(body: string, attrs: Attrs, avail: number): Row[] {
927 const label = (attrs.title ?? body).trim()
928 const color = tone(attrs.tone) ?? 'inactive'
929 const w = Math.min(avail, Number(attrs.width) || avail)
930 if (!label) return [{ runs: [{ fg: color, text: '─'.repeat(w) }] }]
931 const text = rich(label, { bold: true, fg: tone(attrs.tone) })
932 const side = Math.max(2, w - runsWidth(text) - 2)
933
934 return [{ runs: [{ fg: color, text: '──' + ' ' }, ...text, { fg: color, text: ' ' + '─'.repeat(Math.max(0, side - 2)) }] }]
935}
936
937// A banner's spaced, gradient letters, its subtitle and its border color.
938export function bannerParts(body: string, attrs: Attrs): { text: string; letters: Run[]; sub?: string; border: Style } {
939 const text = (attrs.title ?? body).trim().toUpperCase()
940 const chars = [...[...text].join(' ')]
941 const from = hexOf(attrs.from, '#d77757')
942 const to = hexOf(attrs.to, '#af87ff')
943 const letters: Run[] = chars.map((ch, i) => ({ fg: mix(from, to, chars.length > 1 ? i / (chars.length - 1) : 0), bold: true, text: ch }))
944
945 return { text, letters, sub: attrs.sub ?? attrs.subtitle, border: { fg: tone(attrs.tone) ?? 'inactive' } }
946}
947
948export function banner(body: string, attrs: Attrs, avail: number): Row[] {
949 const { text, letters, sub, border } = bannerParts(body, attrs)
950 const spaced = letters.map(r => r.text).join('')
951 const inner = Math.min(avail - 2, Math.max(width(spaced), sub ? width(sub) : 0) + 6)
952 if (width(spaced) + 4 > avail - 2) return [{ runs: rich(text, { bold: true, fg: 'claude' }) }]
953 const lead = Math.floor((inner - width(spaced)) / 2)
954
955 const rows: Row[] = [
956 { runs: [{ ...border, text: '╭' + '─'.repeat(inner) + '╮' }] },
957 { runs: [{ ...border, text: '│' + ' '.repeat(lead) }, ...letters, { ...border, text: ' '.repeat(inner - lead - width(spaced)) + '│' }] },
958 ]
959 if (sub) {
960 const s = clip(sub, inner - 2)
961 const l = Math.floor((inner - width(s)) / 2)
962 rows.push({ runs: [{ ...border, text: '│' + ' '.repeat(l) }, { ...DIM, italic: true, text: s }, { ...border, text: ' '.repeat(inner - l - width(s)) + '│' }] })
963 }
964 rows.push({ runs: [{ ...border, text: '╰' + '─'.repeat(inner) + '╯' }] })
965
966 return rows
967}
968
969export const WIDGETS: Record<string, (body: string, attrs: Attrs, avail: number) => Row[]> = {
970 flow,
971 seq,
972 bars,
973 chart,
974 heat,
975 tree,
976 steps,
977 timeline,
978 kv,
979 rule,
980 banner,
981}
982types/index.d.ts 12 lines1// The decorated mod's session state.
2
3// When the last animation frame was asked for, in ms since the epoch.
4export type Tick = number
5
6declare module 'claude-code' {
7 interface PluginState {
8 // The replies playing an animated <svg> read it, so each write draws a frame.
9 decorated: { tick: Tick }
10 }
11}
12