Merge pull request #4103 from ruvnet/docs/readme-tui

docs: mention the optional standalone ruflo tui (README, user guide, animated SVGs)
This commit is contained in:
rUv authored and GitHub committed 2026-10-12 09:44:23 -07:00
commit f2ae86269b
5 files changed
+218

No files matched your search

+4
View File
@@ -49,6 +49,10 @@ npx -y ruflo@latest mcp start
MCP exposes Ruflo tools to your client. The console and mods above are Claude Code integrations; hook support depends on the client.
<img src="docs/assets/readme/icons/terminal.svg" width="28" height="28" alt=""> **Prefer a plain terminal? Optional.** The console also runs on its own, with no Claude Code: `npx ruflo tui` (add `--plain` to print one frame for scripts). It shows Overview, Cost, Swarm, Events and Missions, plus an optional AI page. [Terminal UI guide](docs/USERGUIDE.md#terminal-ui-optional)
<p align="center"><img src="docs/assets/ruflo-tui-compact.svg" width="640" alt="Looping recording of the optional ruflo terminal UI cycling through Overview, Cost, Swarm, Events and Missions in a sample project"></p>
[Compare install paths](#quick-start) · [Open the console](#console-walkthrough) · [User guide](docs/USERGUIDE.md)
<div align="center">
+55
View File
@@ -578,6 +578,61 @@ Install Ruflo as a native Claude Code plugin -- adds skills, commands, agents, a
After installing, new `/slash-commands` and agent types are available immediately. Run `/reload-plugins` if needed.
### Terminal UI (optional)
The Claude Code console (`/ruflo`) stays the main way to use Ruflo. If you are not in Claude Code, `ruflo tui` shows the same pages in any terminal. It is optional: nothing else depends on it.
<p align="center"><img src="assets/ruflo-tui-walkthrough.svg" width="100%" alt="Looping recording of ruflo tui: Overview, Cost, Swarm, Events and Missions, then the Swarm page in a small 80 by 24 window. Sample project and sample agents only."></p>
*The recording is a real run in a throwaway sample project, so some pages say `n/a` or "none yet".*
**Requirements:** Node.js 20 or later and `ruflo` 3.56.7 or later (`npx ruflo@latest tui`). Use `ruflo` or `@claude-flow/cli`: `npx claude-flow tui` does not ship the bundle and says so.
```bash
npx ruflo tui # open it in this terminal
npx ruflo tui --view swarm # start on a page: overview | cost | swarm | events | missions | ai
npx ruflo tui --plain | head -40 # print one plain frame and exit 0 (scripts, CI)
```
Without a terminal and without `--plain`, `tui` prints a one-line hint and exits 2.
| Key | Does |
|---|---|
| `1`–`6`, or `o` `c` `s` `e` `m` `a` | Go to a page (Overview, Cost, Swarm, Events, Missions, AI) |
| `Tab` / `Shift-Tab` | Next / previous page |
| `j` `k` or the arrow keys | Scroll |
| `r` | Read the files again |
| `q` or `Ctrl-C` | Quit |
**What each page shows.** It reads Ruflo's own files and the CLI's output in the current project, and says `n/a` for anything it cannot measure.
- **Overview:** setup steps, health findings and the subsystems.
- **Cost:** budget and spend across Claude Code and Codex. Missing cost reports are not counted as zero.
- **Swarm:** topology, agents and the hive-mind.
- **Events:** swarm, claim, memory and mod events. History is kept only when the `eventsPersist` option is on.
- **Missions:** the mission record, read-only. Missions are made from a goal in the Claude Code console.
- **AI (optional):** ask Codex or OpenRouter a question. This is the only page that can send anything, and it always asks first.
**The AI page asks first.** Pressing Enter does not send. It shows a card with exactly what would go out, and only a `y` typed while the card is on screen sends it. `n` cancels, and an unanswered card expires after 60 seconds. This is a real card:
```text
Ask first, #1: this goes to OpenRouter
POST https://openrouter.ai/api/v1/chat/completions Authorization: Bearer ‹from
$OPENROUTER_API_KEY› {"model":"openrouter/auto","messages":[{"role":"user","content":"Summarize
what my agents did today"}],"usage":{"include":true}}
The key is read from the named variable, sent in one header and never shown or stored. Metered by the provider.
y sends it · n cancels · it expires in 59 s
```
- **Keys on this page:** typing is text, so `Esc` hands the keys back to the page keys. `Ctrl-T` switches between Codex and OpenRouter, `Ctrl-S` stops a running answer, and `Ctrl-C` quits.
- **Codex** runs only as the pinned `0.162.0` release found under `~/.codex/packages/standalone/releases`, read-only. It never uses the `current` link or `PATH` (0.162.1 crashes). If that release is not installed the page says so.
- **OpenRouter** needs `OPENROUTER_API_KEY` in your environment. The key is named by its variable and never shown. Requests go only to `https://openrouter.ai` and to the HTTPS hosts you name with `--convo-targets` (or `RUFLO_TUI_CONVO_TARGETS`, syntax `endpoint:<name>=<https-base-url>|<KEY_ENV>|<model>;...`), with no redirects. `--model` picks the model (default `openrouter/auto`), and `--max-spend-usd` stops sending once the provider-reported spend reaches the cap (default `0.5`).
- `--no-ai` turns the AI page off.
**Limits.** Read-only except the AI page. Tested on Linux in tmux with Node 22; Windows, macOS and Node 20 are untested. Design and decisions: [ADR-489](../v3/docs/adr/ADR-489-ruflo-tui-standalone-console.md).
**In Claude Code**, the same console has an optional Orbital look and a Theme Studio: run `/ruflo themes`. See the [console README](../plugins/ruflo-console/README.md#orbital-theme).
<details>
<summary>🤖 <strong>OpenAI Codex CLI Support</strong> — Full Codex integration with self-learning</summary>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="666" viewBox="0 0 1024 666" role="img" aria-labelledby="t d"><title id="t">ruflo tui: the console in your terminal</title><desc id="d">A looping recording of the optional standalone ruflo terminal UI cycling through five read-only pages: Overview, Cost, Swarm, Events and Missions. Sample project and sample agents only.</desc><style>.t{font-family:DejaVu Sans Mono, Menlo, Consolas, monospace;font-size:16px}.cap{font:600 18px system-ui,Segoe UI,Helvetica,Arial,sans-serif;fill:#e6edf3}.num{font:14px DejaVu Sans Mono, Menlo, Consolas, monospace;fill:#8b949e}.p{opacity:0;animation:show 13s infinite}.p0{animation-delay:0s}.p1{animation-delay:2.6s}.p2{animation-delay:5.2s}.p3{animation-delay:7.8s}.p4{animation-delay:10.4s}@keyframes show{0%,19.990%{opacity:1}20.000%,100%{opacity:0}}@media (prefers-reduced-motion:reduce){.p{animation:none}.p0{opacity:1}}</style><defs><g id="r0"><rect fill="#c9d1d9" x="0" y="0" width="120" height="19"/><text fill="#0d1117" x="10" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">1</text><text fill="#0d1117" x="30" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">Overview</text><text fill="#c9d1d9" x="140" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">2</text><text fill="#c9d1d9" x="160" y="14.5" textLength="40" lengthAdjust="spacing" xml:space="preserve">Cost</text><text fill="#c9d1d9" x="230" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">3</text><text fill="#c9d1d9" x="250" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">Swarm</text><text fill="#c9d1d9" x="330" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">4</text><text fill="#c9d1d9" x="350" y="14.5" textLength="60" lengthAdjust="spacing" xml:space="preserve">Events</text><text fill="#c9d1d9" x="440" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">5</text><text fill="#c9d1d9" x="460" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">Missions</text><text fill="#c9d1d9" x="570" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">6</text><text fill="#c9d1d9" x="590" y="14.5" textLength="20" lengthAdjust="spacing" xml:space="preserve">AI</text></g><g id="r2"><text x="1" y="14.5" font-size="15" font-family="Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji, DejaVu Sans Mono, Menlo, Consolas, monospace">🏠</text><text fill="rgb(215,135,95)" font-weight="700" x="30" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">Overview</text><text fill="rgb(215,135,95)" font-weight="700" x="120" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">—</text><text fill="rgb(215,135,95)" font-weight="700" x="140" y="14.5" textLength="40" lengthAdjust="spacing" xml:space="preserve">what</text><text fill="rgb(215,135,95)" font-weight="700" x="190" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">ruflo</text><text fill="rgb(215,135,95)" font-weight="700" x="250" y="14.5" textLength="20" lengthAdjust="spacing" xml:space="preserve">is</text><text fill="rgb(215,135,95)" font-weight="700" x="280" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">doing</text><text fill="rgb(215,135,95)" font-weight="700" x="340" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">here:</text><text fill="rgb(215,135,95)" font-weight="700" x="400" y="14.5" textLength="110" lengthAdjust="spacing" xml:space="preserve">subsystems,</text><text fill="rgb(215,135,95)" font-weight="700" x="520" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">mods,</text><text fill="rgb(215,135,95)" font-weight="700" x="580" y="14.5" textLength="60" lengthAdjust="spacing" xml:space="preserve">health</text><text fill="rgb(215,135,95)" font-weight="700" x="650" y="14.5" textLength="60" lengthAdjust="spacing" xml:space="preserve">alerts</text><text fill="rgb(215,135,95)" font-weight="700" x="720" y="14.5" textLength="30" lengthAdjust="spacing" xml:space="preserve">and</text><text fill="rgb(215,135,95)" font-weight="700" x="760" y="14.5" textLength="40" lengthAdjust="spacing" xml:space="preserve">live</text><text fill="rgb(215,135,95)" font-weight="700" x="810" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">activity</text></g><g id="r4"><path transform="translate(0,0)" d="M5 19V12Q5 9.5 8 9.5H10" fill="none" stroke="rgb(175,175,255)" stroke-width="1.2"/><path d="M10 9.5h980" stroke="rgb(175,175,255)" stroke-width="1.2" fill="none"/><path transform="translate(990,0)" d="M5 19V12Q5 9.5 2 9.5H0" fill="none" stroke="rgb(175,175,255)" stroke-width="1.2"/></g><g id="r6"><path transform="translate(0,0)" d="M5 0V19" fill="none" stroke="#c9d1d9" stroke-width="1.2"/><text fill="#c9d1d9" font-weight="700" x="20" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">Start</text><text fill="#c9d1d9" font-weight="700" x="80" yLine truncated

After

Width:  |  Height:  |  Size: 133 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="666" viewBox="0 0 1024 666" role="img" aria-labelledby="t d"><title id="t">ruflo tui walkthrough</title><desc id="d">A looping walkthrough of the optional standalone ruflo terminal UI: the five read-only pages, then the same Swarm page in a small 80 by 24 window. Sample project and sample agents only.</desc><style>.t{font-family:DejaVu Sans Mono, Menlo, Consolas, monospace;font-size:16px}.cap{font:600 18px system-ui,Segoe UI,Helvetica,Arial,sans-serif;fill:#e6edf3}.num{font:14px DejaVu Sans Mono, Menlo, Consolas, monospace;fill:#8b949e}.p{opacity:0;animation:show 20.4s infinite}.p0{animation-delay:0s}.p1{animation-delay:3.4s}.p2{animation-delay:6.8s}.p3{animation-delay:10.2s}.p4{animation-delay:13.6s}.p5{animation-delay:17s}@keyframes show{0%,16.657%{opacity:1}16.667%,100%{opacity:0}}@media (prefers-reduced-motion:reduce){.p{animation:none}.p0{opacity:1}}</style><defs><g id="r0"><rect fill="#c9d1d9" x="0" y="0" width="120" height="19"/><text fill="#0d1117" x="10" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">1</text><text fill="#0d1117" x="30" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">Overview</text><text fill="#c9d1d9" x="140" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">2</text><text fill="#c9d1d9" x="160" y="14.5" textLength="40" lengthAdjust="spacing" xml:space="preserve">Cost</text><text fill="#c9d1d9" x="230" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">3</text><text fill="#c9d1d9" x="250" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">Swarm</text><text fill="#c9d1d9" x="330" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">4</text><text fill="#c9d1d9" x="350" y="14.5" textLength="60" lengthAdjust="spacing" xml:space="preserve">Events</text><text fill="#c9d1d9" x="440" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">5</text><text fill="#c9d1d9" x="460" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">Missions</text><text fill="#c9d1d9" x="570" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">6</text><text fill="#c9d1d9" x="590" y="14.5" textLength="20" lengthAdjust="spacing" xml:space="preserve">AI</text></g><g id="r2"><text x="1" y="14.5" font-size="15" font-family="Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji, DejaVu Sans Mono, Menlo, Consolas, monospace">🏠</text><text fill="rgb(215,135,95)" font-weight="700" x="30" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">Overview</text><text fill="rgb(215,135,95)" font-weight="700" x="120" y="14.5" textLength="10" lengthAdjust="spacing" xml:space="preserve">—</text><text fill="rgb(215,135,95)" font-weight="700" x="140" y="14.5" textLength="40" lengthAdjust="spacing" xml:space="preserve">what</text><text fill="rgb(215,135,95)" font-weight="700" x="190" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">ruflo</text><text fill="rgb(215,135,95)" font-weight="700" x="250" y="14.5" textLength="20" lengthAdjust="spacing" xml:space="preserve">is</text><text fill="rgb(215,135,95)" font-weight="700" x="280" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">doing</text><text fill="rgb(215,135,95)" font-weight="700" x="340" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">here:</text><text fill="rgb(215,135,95)" font-weight="700" x="400" y="14.5" textLength="110" lengthAdjust="spacing" xml:space="preserve">subsystems,</text><text fill="rgb(215,135,95)" font-weight="700" x="520" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">mods,</text><text fill="rgb(215,135,95)" font-weight="700" x="580" y="14.5" textLength="60" lengthAdjust="spacing" xml:space="preserve">health</text><text fill="rgb(215,135,95)" font-weight="700" x="650" y="14.5" textLength="60" lengthAdjust="spacing" xml:space="preserve">alerts</text><text fill="rgb(215,135,95)" font-weight="700" x="720" y="14.5" textLength="30" lengthAdjust="spacing" xml:space="preserve">and</text><text fill="rgb(215,135,95)" font-weight="700" x="760" y="14.5" textLength="40" lengthAdjust="spacing" xml:space="preserve">live</text><text fill="rgb(215,135,95)" font-weight="700" x="810" y="14.5" textLength="80" lengthAdjust="spacing" xml:space="preserve">activity</text></g><g id="r4"><path transform="translate(0,0)" d="M5 19V12Q5 9.5 8 9.5H10" fill="none" stroke="rgb(175,175,255)" stroke-width="1.2"/><path d="M10 9.5h980" stroke="rgb(175,175,255)" stroke-width="1.2" fill="none"/><path transform="translate(990,0)" d="M5 19V12Q5 9.5 2 9.5H0" fill="none" stroke="rgb(175,175,255)" stroke-width="1.2"/></g><g id="r6"><path transform="translate(0,0)" d="M5 0V19" fill="none" stroke="#c9d1d9" stroke-width="1.2"/><text fill="#c9d1d9" font-weight="700" x="20" y="14.5" textLength="50" lengthAdjust="spacing" xml:space="preserve">Start</text><text fill="#c9d1d9" font-weight="700"Line truncated

After

Width:  |  Height:  |  Size: 157 KiB

+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Renders captured terminal frames (`tmux capture-pane -p -e`, ANSI text) as ONE animated, self-contained SVG: CSS keyframes
* only, no scripts, images, fonts or external references, and `prefers-reduced-motion` shows the first frame. Node only.
*
* node scripts/render-tui-svg.mjs spec.json
*
* spec.json: { "out": "docs/assets/x.svg", "title": "...", "desc": "...", "seconds": 2.5, "chrome": "npx ruflo tui",
* "frames": [{ "ansi": "frame.ans", "caption": "plain-language caption" }] }
* Frames may differ in size; the canvas fits the largest and each frame is centred.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
const CW = 10, CH = 19, FONT = 'DejaVu Sans Mono, Menlo, Consolas, monospace'
const BG = '#0d1117', FG = '#c9d1d9'
const esc = s => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
/** xterm 256-colour palette. */
function xterm(n) {
const base = ['#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5',
'#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff']
if (n < 16) return base[n]
if (n >= 232) { const v = 8 + (n - 232) * 10; return `rgb(${v},${v},${v})` }
const i = n - 16, lv = k => (k === 0 ? 0 : 55 + k * 40)
return `rgb(${lv(Math.floor(i / 36))},${lv(Math.floor(i / 6) % 6)},${lv(i % 6)})`
}
/** Terminal cell width: emoji and CJK take two cells, variation selectors none. */
function width(cp) {
if (cp === 0xfe0f || cp === 0xfe0e || cp === 0x200d) return 0
if ((cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xff00 && cp <= 0xff60)) return 2
return 1
}
/** One ANSI line to cells [{ ch, w, fg, bg, bold }] on a fixed-width grid. */
function parseLine(line, cols) {
const cells = []
let fg = null, bg = null, bold = false, dim = false, rev = false
const re = /\x1b\[([0-9;:]*)m|\x1b\[[0-9;?]*[A-Za-z]|([\s\S])/gu
let m
while ((m = re.exec(line))) {
if (m[2] === undefined) {
if (m[1] === undefined) continue
const p = m[1] === '' ? [0] : m[1].split(/[;:]/).map(Number)
for (let i = 0; i < p.length; i++) {
const c = p[i]
if (c === 0) { fg = bg = null; bold = dim = rev = false }
else if (c === 1) bold = true
else if (c === 2) dim = true
else if (c === 22) bold = dim = false
else if (c === 7) rev = true
else if (c === 27) rev = false
else if (c >= 30 && c <= 37) fg = xterm(c - 30)
else if (c >= 90 && c <= 97) fg = xterm(c - 90 + 8)
else if (c >= 40 && c <= 47) bg = xterm(c - 40)
else if (c >= 100 && c <= 107) bg = xterm(c - 100 + 8)
else if (c === 39) fg = null
else if (c === 49) bg = null
else if ((c === 38 || c === 48) && p[i + 1] === 5) { const v = xterm(p[i + 2]); if (c === 38) fg = v; else bg = v; i += 2 }
else if ((c === 38 || c === 48) && p[i + 1] === 2) { const v = `rgb(${p[i + 2]},${p[i + 3]},${p[i + 4]})`; if (c === 38) fg = v; else bg = v; i += 4 }
}
continue
}
const ch = m[2]
if (ch === '\n' || ch === '\r') continue
const w = width(ch.codePointAt(0))
if (w === 0) continue
let f = fg ?? FG, b = bg
if (rev) { const t = f; f = b ?? BG; b = t }
if (dim) f = `color-mix(in srgb, ${f} 60%, ${BG})`
cells.push({ ch, w, fg: f, bg: b, bold })
}
let x = 0
for (const c of cells) { c.x = x; x += c.w }
while (x < cols) { cells.push({ ch: ' ', w: 1, fg: FG, bg: null, bold: false, x }); x++ }
return cells
}
const LINES = { '─': 'M0 9.5H10', '│': 'M5 0V19', '╭': 'M5 19V12Q5 9.5 8 9.5H10', '╮': 'M5 19V12Q5 9.5 2 9.5H0', '╰': 'M5 0V7Q5 9.5 8 9.5H10', '╯': 'M5 0V7Q5 9.5 2 9.5H0' }
const rows = new Map()
/** Deduplicated row definition shared by every frame (borders and headers repeat a lot). */
function rowId(cells) {
const key = JSON.stringify(cells.map(c => [c.ch, c.fg, c.bg, c.bold]))
if (rows.has(key)) return rows.get(key)
const id = `r${rows.size}`, out = []
for (let s = 0, e; s < cells.length; s = e) {
const color = cells[s].bg
for (e = s + 1; e < cells.length && cells[e].bg === color; e++);
if (color) out.push(`<rect fill="${color}" x="${cells[s].x * CW}" y="0" width="${(cells[e - 1].x + cells[e - 1].w - cells[s].x) * CW}" height="${CH}"/>`)
}
let run = null, hl = null
// Monospace runs use textLength so every font lands on the 10px grid without a per-glyph x list (keeps the file small).
const flush = () => {
if (run) out.push(`<text fill="${run.fg}"${run.bold ? ' font-weight="700"' : ''} x="${run.x0}" y="14.5" textLength="${run.n * CW}" lengthAdjust="spacing" xml:space="preserve">${esc(run.t)}</text>`)
run = null
}
const flushHl = () => { if (hl) out.push(`<path d="M${hl.x0} 9.5h${hl.n * CW}" stroke="${hl.fg}" stroke-width="1.2" fill="none"/>`); hl = null }
for (const c of cells) {
const x = c.x * CW
if (c.ch !== '─') flushHl()
if (c.ch === '█' || c.ch === '▀' || c.ch === '▄') {
flush()
out.push(`<rect fill="${c.fg}" x="${x}" y="${c.ch === '▄' ? 9.5 : 0}" width="${CW}" height="${c.ch === '█' ? CH : 9.5}"/>`)
} else if (c.ch === '─') {
flush()
if (hl && hl.fg === c.fg && hl.x0 + hl.n * CW === x) hl.n++
else { flushHl(); hl = { fg: c.fg, x0: x, n: 1 } }
} else if (LINES[c.ch]) {
flush()
out.push(`<path transform="translate(${x},0)" d="${LINES[c.ch]}" fill="none" stroke="${c.fg}" stroke-width="1.2"/>`)
} else if (c.w === 2) {
flush()
out.push(`<text x="${x + 1}" y="14.5" font-size="15" font-family="Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji, ${FONT}">${esc(c.ch)}</text>`)
} else if (c.ch === ' ') flush()
else if (run && run.fg === c.fg && run.bold === c.bold && run.x0 + run.n * CW === x) { run.t += c.ch; run.n++ }
else { flush(); run = { fg: c.fg, bold: c.bold, t: c.ch, x0: x, n: 1 } }
}
flush()
flushHl()
rows.set(key, id)
rows.set(`def:${id}`, `<g id="${id}">${out.join('')}</g>`)
return id
}
const specPath = resolve(process.argv[2] ?? '')
const spec = JSON.parse(readFileSync(specPath, 'utf8'))
const base = dirname(specPath)
const frames = spec.frames.map(f => {
const lines = readFileSync(resolve(base, f.ansi), 'utf8').replace(/\s+$/, '').split('\n')
const cols = Math.max(...lines.map(l => parseLine(l, 0).reduce((a, c) => a + c.w, 0)), 1)
return { caption: f.caption, grid: lines.map(l => parseLine(l, cols)), cols }
})
const cols = Math.max(...frames.map(f => f.cols)), nrows = Math.max(...frames.map(f => f.grid.length))
const PAD = 12, BAR = 30, CAP = 54
const W = cols * CW + PAD * 2, H = BAR + nrows * CH + PAD + CAP
const secs = spec.seconds ?? 2.5, total = +(secs * frames.length).toFixed(3)
const groups = frames.map((f, n) => {
const dx = PAD + Math.floor(((cols - f.cols) * CW) / 2)
const uses = f.grid.map((cells, y) => `<use href="#${rowId(cells)}" x="${dx}" y="${BAR + y * CH}"/>`).join('')
const cap = `<text class="cap" x="${PAD}" y="${BAR + nrows * CH + PAD + 24}">${esc(f.caption)}</text><text class="num" x="${W - PAD}" y="${BAR + nrows * CH + PAD + 24}" text-anchor="end">${n + 1} / ${frames.length}</text>`
return `<g class="p p${n}">${uses}${cap}</g>`
})
const css = `.t{font-family:${FONT};font-size:16px}.cap{font:600 18px system-ui,Segoe UI,Helvetica,Arial,sans-serif;fill:#e6edf3}.num{font:14px ${FONT};fill:#8b949e}` +
`.p{opacity:0;animation:show ${total}s infinite}${frames.map((_, n) => `.p${n}{animation-delay:${+(n * secs).toFixed(3)}s}`).join('')}` +
`@keyframes show{0%,${(100 / frames.length - 0.01).toFixed(3)}%{opacity:1}${(100 / frames.length).toFixed(3)}%,100%{opacity:0}}` +
`@media (prefers-reduced-motion:reduce){.p{animation:none}.p0{opacity:1}}`
const defs = [...rows.entries()].filter(([k]) => k.startsWith('def:')).map(([, v]) => v).join('')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-labelledby="t d">` +
`<title id="t">${esc(spec.title)}</title><desc id="d">${esc(spec.desc)}</desc><style>${css}</style><defs>${defs}</defs>` +
`<rect width="${W}" height="${H}" rx="10" fill="${BG}"/><rect width="${W}" height="${BAR}" rx="10" fill="#161b22"/><rect y="20" width="${W}" height="10" fill="#161b22"/>` +
`<circle cx="20" cy="15" r="5" fill="#ff5f56"/><circle cx="38" cy="15" r="5" fill="#ffbd2e"/><circle cx="56" cy="15" r="5" fill="#27c93f"/>` +
`<text x="${W / 2}" y="20" text-anchor="middle" class="t" font-size="13" fill="#8b949e">${esc(spec.chrome ?? '')}</text>` +
`<g class="t" fill="${FG}">${groups.join('')}</g></svg>\n`
writeFileSync(resolve(base, spec.out), svg)
console.log(`${spec.out}: ${(svg.length / 1024).toFixed(1)} KB, ${frames.length} frames, ${rows.size / 2} unique rows, ${W}x${H}`)