mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
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:
5 files changed
+218
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}`)
|
||||
Reference in new issue
Block a user