/** * README demo runner: drives the real Pi TUI in a pty through a scene file, * writes an asciicast v2 with post-production holds, and renders a GIF with * agg. With `--assert-only` it runs the same scene without writing anything, * so a scene doubles as a terminal E2E assertion in //:check. * * Scene file: extensions//demo/scene.json * { * "title": "footer", * "extensions": ["footer", "cost", "context-size"], // dist packages to load; first = protagonist * "cols": 100, "rows": 28, * "turns": [{ "text": "…" }, { "text": "…", "tool": { "name": "read", "arguments": {…} } }], * "steps": [ * { "expect": "regex", "hold": 1.5 }, * { "type": "/footer", "key": "enter", "expect": "regex", "hold": 2 }, * { "key": "ctrl-c" } * ] * } * Time between steps in the cast is `hold` seconds (default 6 after an * `expect`); real waiting is compressed to at most 50 ms per output chunk. */ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { ROOT, run } from "../lib/common.ts"; export interface SceneStep { type?: string; /** Named key, or `alt-` for an Alt chord. */ key?: string; expect?: string; /** Seconds held on screen after this step in the playback timeline. */ hold?: number; /** Milliseconds to wait for `expect` before failing; default 15000. */ timeout?: number; /** Match `expect` only against output produced after this step's input, * for example once an interactive program has handed the terminal back. */ fresh?: boolean; } export interface Scene { title: string; extensions: string[]; cols?: number; rows?: number; turns?: unknown[]; steps: SceneStep[]; /** Font list for agg; default JetBrains Mono with a Nerd Font fallback. */ font?: string; /** Files seeded into the demo project directory before Pi starts. */ files?: Record; /** Commands (argv arrays) run in the project directory after `files`, for * fixtures a file list cannot express, such as a Git repository. */ setup?: string[][]; /** Regexes (visible text) that are expected to appear although they look like errors. */ allowErrors?: string[]; /** Extra user settings for the sandbox agent dir, for example `ultra.subagentExtensions`. */ settings?: Record; /** Extra `pi` CLI arguments, for example an extension flag such as `--nushell`. */ args?: string[]; } /** Screen text that means the demo went wrong, whatever the scene expected. */ const ERROR_PATTERNS: readonly RegExp[] = [ /No more faux responses queued/u, /\/bug sends a report to the developers/u, /\bError: /u, /Extension error/u, /Failed to load extension/u, ]; const KEYS: Record, string> = { enter: "\r", escape: "\u001b", tab: "\t", "ctrl-c": "\u0003", "ctrl-d": "\u0004", "ctrl-j": "\n", "ctrl-o": "\u000f", up: "\u001b[A", down: "\u001b[B", }; /** `alt-` sends ESC + char, which is how terminals encode Alt chords. */ function keyBytes(key: string): string { const alt = /^alt-(.)$/u.exec(key); if (alt) return `\u001b${alt[1]}`; const bytes = KEYS[key as keyof typeof KEYS]; if (!bytes) throw new Error(`unknown key "${key}"`); return bytes; } const DEFAULT_HOLD = 6; const TYPE_DELAY_S = 0.045; /** Strip ANSI so `expect` regexes match visible text. */ function visible(text: string): string { return text .replaceAll(/\u001b\[[0-9;?]*[A-Za-z]/g, "") .replaceAll(/\u001b\][^\u0007]*\u0007/g, "") .replaceAll(/\u001b[()][A-Z0-9]/g, "") .replaceAll(/\u001b[=>]/g, ""); } function nuguTheme(): string { const palette = JSON.parse( readFileSync(join(ROOT, "assets", "nugu.json"), "utf8"), ) as { dark: { content: Record; ui: Record }; }; const c = palette.dark.content; const u = palette.dark.ui; const hex = (v: string) => v.replace("#", ""); // agg theme: bg, fg, then 16 ANSI colours. Mirrors nugu's ghostty mapping. return [ hex(c["backdrop"] ?? "#121212"), hex(c["normal"] ?? "#ededed"), hex(c["backdrop"] ?? "#121212"), "dc5050", hex(c["focus"] ?? "#7ba1aa"), hex(c["important_local"] ?? "#b59ae4"), hex(c["focus"] ?? "#7ba1aa"), hex(c["accent"] ?? "#d151d1"), hex(c["important_global"] ?? "#dfd3f3"), hex(c["normal"] ?? "#ededed"), hex(c["unfocus"] ?? "#5d5d5d"), "dc5050", hex(u["focus"] ?? "#9aabae"), hex(u["important_local"] ?? "#c9b6eb"), hex(u["focus"] ?? "#9aabae"), hex(u["accent"] ?? "#d86dd8"), hex(u["important_global"] ?? "#f3effb"), hex(u["normal"] ?? "#dbdbdb"), ].join(","); } export interface RunResult { castPath?: string; gifPath?: string; realSeconds: number; playbackSeconds: number; } export async function runScene( extensionName: string, options: { assertOnly: boolean }, ): Promise { const extDir = join(ROOT, "extensions", extensionName); const scenePath = join(extDir, "demo", "scene.json"); if (!existsSync(scenePath)) throw new Error(`${extensionName}: no demo/scene.json`); const scene = JSON.parse(readFileSync(scenePath, "utf8")) as Scene; const cols = scene.cols ?? 100; const rows = scene.rows ?? 28; // Loaded packages come from dist so the demo shows what ships. const extensionArgs = [ ...scene.extensions.flatMap((name) => { const packageDir = join(ROOT, "dist", name); const packagePath = join(packageDir, "package.json"); if (!existsSync(packagePath)) throw new Error( `${extensionName}: dist/${name} missing; run mise run //:build-ext ${name}`, ); const pkg = JSON.parse(readFileSync(packagePath, "utf8")) as { pi?: { extensions?: string[] }; }; const entryNames = pkg.pi?.extensions; if (!entryNames?.length) throw new Error( `${extensionName}: dist/${name} has no Pi extension entry`, ); return entryNames.flatMap((entryName) => { const entry = join(packageDir, entryName); if (!existsSync(entry)) throw new Error( `${extensionName}: dist/${name}/${entryName} missing`, ); return ["-e", entry]; }); }), ]; // Isolated agent dir: no user settings, sessions, or auth leak into the demo. // A stable path so the footer shows `…/pi-demo/` instead of a pid. const sandbox = join(tmpdir(), "pi-demo-sandbox", extensionName); const agentDir = join(sandbox, "agent"); // The demo runs in a neutral project directory so the screen shows the // extension, not this repository; the folder is pre-trusted to skip Pi's // trust prompt. const project = join(tmpdir(), "pi-demo", extensionName); rmSync(sandbox, { recursive: true, force: true }); rmSync(project, { recursive: true, force: true }); mkdirSync(agentDir, { recursive: true }); mkdirSync(project, { recursive: true }); for (const [file, content] of Object.entries(scene.files ?? {})) { const target = join(project, file); mkdirSync(join(target, ".."), { recursive: true }); writeFileSync(target, content); } for (const [command, ...args] of scene.setup ?? []) { if (!command) continue; run(command, args, { cwd: project, check: true, capture: true }); } writeFileSync( join(agentDir, "settings.json"), `${JSON.stringify({ theme: "dark", ...scene.settings }, null, "\t")}\n`, ); // The demo provider lives in the sandbox agent dir so Pi discovers it the // same way for the recorded session and for any child session an // extension such as ultra spawns with the same PI_CODING_AGENT_DIR. const providerDir = join(agentDir, "extensions", "demo-provider"); mkdirSync(providerDir, { recursive: true }); writeFileSync( join(providerDir, "index.ts"), `export { default } from ${JSON.stringify(fileURLToPath(new URL("./provider.ts", import.meta.url)))};\n`, ); writeFileSync( join(agentDir, "trust.json"), `${JSON.stringify({ [project]: true }, null, "\t")}\n`, ); const { spawn } = await import("@lydell/node-pty"); const started = Date.now(); const events: Array<[number, "o", string]> = []; let timeline = 0; let lastReal = started; let screen = ""; /** Everything Pi printed, ANSI stripped, for the error scan. */ let transcript = ""; const reportPath = join(sandbox, "provider-report.json"); const allowed = (scene.allowErrors ?? []).map((p) => new RegExp(p, "u")); // Launch the installed CLI directly; Windows cannot execute a pi.cmd shim in a PTY. const piPackage = new URL( "../package.json", import.meta.resolve("@earendil-works/pi-coding-agent"), ); const piMetadata = JSON.parse(readFileSync(piPackage, "utf8")) as { bin: { pi: string }; }; const proc = spawn( process.execPath, [ fileURLToPath(new URL(piMetadata.bin.pi, piPackage)), "--no-session", "--no-skills", "--no-prompt-templates", "--no-themes", "--provider", "demo", "--model", "demo-model", ...extensionArgs, ...(scene.args ?? []), ], { name: "xterm-256color", cols, rows, cwd: project, env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor", PI_CODING_AGENT_DIR: agentDir, PI_DEMO_SCENE: scenePath, PI_DEMO_REPORT: reportPath, PI_DEMO_TRACE: join(sandbox, "trace.txt"), // No version check, package update check, or telemetry in a recording. PI_OFFLINE: "1", // Deterministic external editor for ctx.ui.editor and Ctrl+G. VISUAL: "nvim", EDITOR: "nvim", HOME: join(sandbox, "home"), XDG_STATE_HOME: join(sandbox, "state"), XDG_CONFIG_HOME: join(sandbox, "config"), XDG_CACHE_HOME: join(sandbox, "cache"), XDG_DATA_HOME: join(sandbox, "data"), }, }, ); proc.onData((data) => { const now = Date.now(); timeline += Math.min((now - lastReal) / 1000, 0.05); lastReal = now; events.push([Number(timeline.toFixed(3)), "o", data]); screen = (screen + data).slice(-40_000); transcript += visible(data); }); let exited = false; proc.onExit(() => { exited = true; }); const waitFor = (pattern: string, ms: number) => new Promise((resolve, reject) => { const re = new RegExp(pattern, "u"); const t0 = Date.now(); const tick = () => { if (re.test(visible(screen))) return resolve(); if (exited) return reject(new Error(`pi exited while waiting for /${pattern}/`)); if (Date.now() - t0 > ms) return reject( new Error( `timeout waiting for /${pattern}/\n--- last screen ---\n${visible(screen).slice(-1500)}`, ), ); setTimeout(tick, 25); }; tick(); }); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); try { // Pi renders its model before binding extension shortcuts; the loaded // resource list appears only after binding finishes. await waitFor("\\[Extensions\\]", 15_000); for (const step of scene.steps) { if (step.fresh) screen = ""; if (step.type) for (const ch of step.type) { proc.write(ch); timeline += TYPE_DELAY_S; await sleep(12); } if (step.key) proc.write(keyBytes(step.key)); if (step.expect) await waitFor(step.expect, step.timeout ?? 15_000); const hold = step.hold ?? (step.expect ? DEFAULT_HOLD : 0); timeline += hold; // Keep only recent output for matching; the cast keeps everything. screen = screen.slice(-12_000); } await sleep(200); } catch (error) { // Keep the whole visible transcript for diagnosis; the sandbox is temp. writeFileSync(join(sandbox, "transcript.txt"), transcript); throw new Error( `${error instanceof Error ? error.message : String(error)}\n--- full transcript: ${join(sandbox, "transcript.txt")}`, ); } finally { if (!exited) proc.kill(); } const realSeconds = (Date.now() - started) / 1000; // A scene passes only when nothing on screen looked like an error and the // model was called exactly as often as turns were scripted; otherwise an // unplanned turn silently eats a reply and the recording lies. for (const pattern of ERROR_PATTERNS) { const match = pattern.exec(transcript); if (!match) continue; if (allowed.some((a) => a.test(match[0]))) continue; const at = Math.max(0, match.index - 200); throw new Error( `screen showed an error: ${match[0]}\n--- context ---\n${transcript.slice(at, match.index + 300)}`, ); } if (existsSync(reportPath)) { const report = JSON.parse(readFileSync(reportPath, "utf8")) as { queued: number; calls: number; }; // Child sessions replay their own list; only the primary queue is // accounted here. if (report.calls !== report.queued) throw new Error( `scripted ${report.queued} model turn(s) but Pi made ${report.calls} model call(s); align scene.turns with the prompts the steps actually send`, ); } if (options.assertOnly) return { realSeconds, playbackSeconds: timeline }; const header = JSON.stringify({ version: 2, width: cols, height: rows, timestamp: Math.floor(started / 1000), title: scene.title, env: { TERM: "xterm-256color" }, }); const cast = `${[header, ...events.map((e) => JSON.stringify(e))].join("\n")}\n`; const assets = join(extDir, "assets"); mkdirSync(assets, { recursive: true }); const castPath = join(assets, "demo.cast"); const gifPath = join(assets, "demo.gif"); writeFileSync(castPath, cast); run( "agg", [ "--theme", nuguTheme(), "--font-family", scene.font ?? "JetBrains Mono,MesloLGM Nerd Font Mono", "--font-size", "16", "--idle-time-limit", // Preserve authored reading pauses; agg otherwise caps them at 5 s. "60", castPath, gifPath, ], { cwd: ROOT, check: true }, ); return { castPath, gifPath, realSeconds, playbackSeconds: timeline }; } function main(argv: string[]): Promise { const assertOnly = argv.includes("--assert-only"); const names = argv.filter((a) => !a.startsWith("--")); const withScene = readdirScenes(); // Release gates pass their whole selection; extensions without a scene are // not a failure there, only when someone asks to record them. const targets = names.length ? assertOnly ? names.filter((name) => withScene.includes(name)) : names : withScene; return (async () => { let failed = 0; for (const name of targets) { try { const result = await runScene(name, { assertOnly }); const where = result.gifPath ? ` → ${relative(ROOT, result.gifPath)} (${(statSize(result.gifPath) / 1024).toFixed(0)} KB)` : ""; console.log( `OK ${name}: ${result.realSeconds.toFixed(1)}s real, ${result.playbackSeconds.toFixed(1)}s playback${where}`, ); } catch (error) { failed++; console.error( `FAIL ${name}: ${error instanceof Error ? error.message : String(error)}`, ); } } return failed ? 1 : 0; })(); } export function readdirScenes(): string[] { const root = join(ROOT, "extensions"); return readdirSync(root) .filter((name) => existsSync(join(root, name, "demo", "scene.json"))) .sort(); } const statSize = (path: string): number => statSync(path).size; if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(process.argv.slice(2)).then((code) => { process.exitCode = code; });