#!/usr/bin/env bun /** * PodTUI LLM-interactive harness — stateless per-turn snapshot bridge. * * Each invocation: * 1. Points XDG_CONFIG_HOME / XDG_DATA_HOME / PODTUI_AUDIO_BACKEND at a * sandbox dir under .harness so your real ~/.config/podtui is never touched. * 2. Replays the saved action log (.harness/actions.json) from scratch. * 3. Appends + executes the new action passed on the CLI. * 4. Renders, captures structured spans, and prints: plain frame + distinct * style summary (colors/attrs) + selected store state + captured issues * (stderr / uncaught rejections). Full structured spans are dumped to * .harness/last-frame.json every turn. * * Audio is silent (Noop) by default during the snapshot model so replaying the * log each turn doesn't re-trigger real playback. Pass --audio (or set * PODTUI_AUDIO_BACKEND) to flip to a real backend for the new action only. * * Import order mirrors src/index.tsx (lazy) to avoid a NavigationContext cycle. * * Usage: * bun scripts/tui-harness.tsx init [--size 100x30] [--seed] * bun scripts/tui-harness.tsx key [mods...] # mods: ctrl shift meta * bun scripts/tui-harness.tsx arrow [mods...] * bun scripts/tui-harness.tsx enter|escape|tab|space|backspace * bun scripts/tui-harness.tsx type "" * bun scripts/tui-harness.tsx wait * bun scripts/tui-harness.tsx resize * bun scripts/tui-harness.tsx frame # re-render, no new action * bun scripts/tui-harness.tsx state [all|nav|audio|feed|app] * bun scripts/tui-harness.tsx actions # print action log * bun scripts/tui-harness.tsx reset * bun scripts/tui-harness.tsx seed [--from ~/.config/podtui] * * Flags (after the subcommand): * --size WxH terminal size (default 100x30) * --audio enable real audio backend for the new action only * --no-settle skip the extra render-settle loops * --styles print the distinct-styles sample block (off by default) * --verbose restore the original multi-line pretty output */ import { testRender } from "@opentui/solid"; import { existsSync, mkdirSync, cpSync, writeFileSync, readFileSync, } from "node:fs"; import { join } from "node:path"; // ── Paths & sandbox ──────────────────────────────────────────────────────── const HANDLES_DIR = ".harness"; const CONFIG_HOME = join(HANDLES_DIR, "config-home"); const DATA_HOME = join(HANDLES_DIR, "data-home"); const ACTIONS_FILE = join(HANDLES_DIR, "actions.json"); const FRAME_JSON = join(HANDLES_DIR, "last-frame.json"); const FRAME_TXT = join(HANDLES_DIR, "last-frame.txt"); const STATE_JSON = join(HANDLES_DIR, "state.json"); // Sandbox must be active BEFORE any app module is imported, so the app's // config-dir / persistence reads resolve into .harness/*. function activateSandbox(): void { mkdirSync(CONFIG_HOME, { recursive: true }); mkdirSync(DATA_HOME, { recursive: true }); process.env.XDG_CONFIG_HOME = join(process.cwd(), CONFIG_HOME); process.env.XDG_DATA_HOME = join(process.cwd(), DATA_HOME); // Silent audio during replay by default; --audio flips this after import. if (!process.env.PODTUI_AUDIO_BACKEND) process.env.PODTUI_AUDIO_BACKEND = "none"; } // ── Action log ───────────────────────────────────────────────────────────── type Mod = "ctrl" | "shift" | "meta" | "super" | "hyper"; type Action = | { t: "key"; k: string; mods?: Mod[] } | { t: "arrow"; d: "up" | "down" | "left" | "right"; mods?: Mod[] } | { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] } | { t: "type"; s: string } | { t: "wait"; ms: number } | { t: "resize"; w: number; h: number }; function loadActions(): Action[] { try { return JSON.parse(readFileSync(ACTIONS_FILE, "utf8") || "[]"); } catch { return []; } } function saveActions(a: Action[]): void { writeFileSync(ACTIONS_FILE, JSON.stringify(a, null, 2)); } // ── Issue capture ────────────────────────────────────────────────────────── const issues: string[] = []; // Captured by the StateProbe component rendered inside the provider tree — // Solid contexts can only be read from within the tree, not from outside. let navRef: any = null; function captureIssues(): void { const origErr = console.error; const origWarn = console.warn; console.error = (...args: unknown[]) => { issues.push("stderr: " + args.map(String).join(" ")); origErr(...(args as any[])); }; console.warn = (...args: unknown[]) => { issues.push("warn: " + args.map(String).join(" ")); origWarn(...(args as any[])); }; process.on("uncaughtException", (e) => issues.push("uncaught: " + ((e as Error)?.stack || String(e))), ); process.on("unhandledRejection", (e) => issues.push("unhandledRejection: " + ((e as Error)?.stack || String(e))), ); } // ── Span rendering ───────────────────────────────────────────────────────── type RGBA = { r: number; g: number; b: number; a: number }; type Span = { text: string; fg: RGBA | null; bg: RGBA | null; attributes: number; width: number; }; const ATTR_NAMES: Record = { BOLD: 1, DIM: 2, ITALIC: 4, UNDERLINE: 8, BLINK: 16, INVERSE: 32, HIDDEN: 64, STRIKETHROUGH: 128, }; function hex(c: RGBA | null): string | null { if (!c) return null; if (c.a === 0) return null; // transparent → "default" const [r, g, b] = [c.r, c.g, c.b].map((v) => Math.max(0, Math.min(255, Math.round(v))), ); return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join(""); } function attrLabels(attr: number): string[] { const out: string[] = []; for (const [name, bit] of Object.entries(ATTR_NAMES)) if (attr & bit) out.push(name.toLowerCase()); return out; } // (Plain frame text comes from captureCharFrame instead of span reconstruction.) function distinctStyles(spans: { lines: { spans: Span[] }[]; }): { tag: string; sample: string; n: number }[] { const map = new Map(); for (const line of spans.lines) { for (const s of line.spans) { if (!s.text || s.text.trim() === "") continue; const fg = hex(s.fg as any); const bg = hex(s.bg as any); if (!fg && !bg && s.attributes === 0) continue; // default — skip const tags = attrLabels(s.attributes); const tag = `[fg=${fg ?? "·"} bg=${bg ?? "·"}${tags.length ? " " + tags.join("+") : ""}]`; const ex = map.get(tag); const sample = s.text.replace(/\n/g, "\\n").slice(0, 28); if (ex) { ex.n++; if (ex.sample.length < 14 && sample.length > ex.sample.length) ex.sample = sample; } else { map.set(tag, { tag, sample, n: 1 }); } } } return [...map.values()].sort((a, b) => b.n - a.n).slice(0, 20); } // ── Arg parsing ──────────────────────────────────────────────────────────── function parseFlags(rest: string[]): { flags: Record; positional: string[]; } { const flags: Record = {}; const positional: string[] = []; for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (a.startsWith("--")) { if (a === "--audio") flags.audio = true; else if (a === "--no-settle") flags["no-settle"] = true; else if (a === "--styles") flags.styles = true; else if (a === "--verbose") flags.verbose = true; else if (a === "--size") { flags.size = rest[++i]; const m = /(\d+)x(\d+)/.exec(String(flags.size)); if (m) { flags.w = m[1]; flags.h = m[2]; } } else if (a === "--from") { flags.from = rest[++i]; } else { flags[a.slice(2)] = rest[++i] ?? true; } } else { positional.push(a); } } return { flags, positional }; } function parseMods(positional: string[]): Mod[] { const mods: Mod[] = []; for (const p of positional) if (["ctrl", "shift", "meta", "super", "hyper"].includes(p)) mods.push(p as Mod); return mods; } function buildAction(cmd: string, positional: string[]): Action | null { const mods = parseMods(positional); const first = positional[0]; switch (cmd) { case "key": if (!first) throw new Error("key requires a argument"); return { t: "key", k: first, mods: mods.length ? mods : undefined }; case "arrow": if (!first || !["up", "down", "left", "right"].includes(first)) throw new Error("arrow requires up|down|left|right"); return { t: "arrow", d: first as any, mods: mods.length ? mods : undefined, }; case "enter": case "escape": case "tab": case "space": case "backspace": return { t: cmd, mods: mods.length ? mods : undefined }; case "type": if (first === undefined) throw new Error("type requires "); // Re-join the rest in case text had spaces; positional[0] already is first token, // caller should quote. We join all positional as the text. return { t: "type", s: positional.join(" ") }; case "wait": if (!first) throw new Error("wait requires "); return { t: "wait", ms: parseInt(first, 10) || 0 }; case "resize": if (!first || !positional[1]) throw new Error("resize requires "); return { t: "resize", w: parseInt(first, 10) || 100, h: parseInt(positional[1], 10) || 30, }; case "frame": case "state": case "reset": case "actions": case "init": case "seed": return null; default: throw new Error(`unknown command: ${cmd}`); } } // ── Execute one action against a mounted setup ────────────────────────────── function fmtMods(mods?: Mod[]): Record | undefined { if (!mods || !mods.length) return undefined; const o: Record = {}; for (const m of mods) o[m] = true; return o; } async function execAction(setup: any, a: Action): Promise { const mi = setup.mockInput; switch (a.t) { case "key": mi.pressKey(a.k, fmtMods(a.mods)); break; case "arrow": mi.pressArrow(a.d, fmtMods(a.mods)); break; case "enter": mi.pressEnter(fmtMods(a.mods)); break; case "escape": mi.pressEscape(fmtMods(a.mods)); break; case "tab": mi.pressTab(fmtMods(a.mods)); break; case "space": mi.pressKey("space"); break; case "backspace": mi.pressBackspace(fmtMods(a.mods)); break; case "type": await mi.typeText(a.s, 0); break; case "wait": await new Promise((r) => setTimeout(r, a.ms)); break; case "resize": setup.resize(a.w, a.h); break; } await setup.renderOnce(); // tiny settle for reactive updates await new Promise((r) => setTimeout(r, 40)); } // ── Main ─────────────────────────────────────────────────────────────────── async function main() { activateSandbox(); captureIssues(); const argv = process.argv.slice(2); const cmd = argv[0] ?? "frame"; const { flags, positional } = parseFlags(argv.slice(1)); // Local-only commands that don't mount. if (cmd === "reset") { saveActions([]); console.log("✔ actions log cleared."); return; } if (cmd === "actions") { const a = loadActions(); console.log(`Action log (${a.length}):`); console.log(JSON.stringify(a, null, 2)); return; } if (cmd === "seed") { const from = String( flags.from || join(process.env.HOME || "~", ".config", "podtui"), ); if (!existsSync(from)) { console.error(`seed source not found: ${from}`); process.exit(1); } const dest = join(process.env.XDG_CONFIG_HOME!, "podtui"); cpSync(from, dest, { recursive: true }); console.log(`✔ seeded sandbox config from ${from} → ${dest}`); return; } // Size settings. let width = 100; let height = 30; if (flags.w) width = parseInt(String(flags.w), 10); if (flags.h) height = parseInt(String(flags.h), 10); let newAction: Action | null = null; let actions: Action[] = []; if (cmd !== "init" && cmd !== "frame" && cmd !== "state") { newAction = buildAction(cmd, positional); } if (cmd === "init") { saveActions([]); actions = []; } else { actions = loadActions(); } // Mount the real app. Lazy imports (order matters — see NavigationContext cycle). const { App } = await import("../src/App"); const { ThemeProvider } = await import("../src/context/ThemeContext"); const toast = await import("../src/ui/toast"); const { KeybindProvider } = await import("../src/context/KeybindContext"); const { NavigationProvider, useNavigation } = await import( "../src/context/NavigationContext" ); const { DialogProvider } = await import("../src/ui/dialog"); const { CommandProvider } = await import("../src/ui/command"); // Probe rendered inside the provider tree so context hooks resolve. const StateProbe = () => { try { navRef = useNavigation(); } catch (e) { issues.push("StateProbe: " + String(e)); } return null; }; const HarnessRoot = () => ( ); const setup = await testRender(() => , { width, height, useThread: false, }); (setup.renderer as any).disableStdoutInterception?.(); // Wait for providers (keybinds/theme/feeds) to settle. const settleLoops = flags["no-settle"] ? 2 : 12; for (let i = 0; i < settleLoops; i++) { await setup.renderOnce(); await new Promise((r) => setTimeout(r, 60)); } // Replay history silently (audio already Noop via env). for (const a of actions) await execAction(setup, a); // For the *new* action: if --audio, flip to a real backend just for it. let audioControls: any = null; try { const { useAudio } = await import("../src/hooks/useAudio"); audioControls = useAudio(); } catch (e) { issues.push("useAudio import: " + String(e)); } if (newAction) { if (flags.audio && audioControls?.switchBackend) { // Force (re)creation of a real backend; useAudio caches, switchBackend resets. delete process.env.PODTUI_AUDIO_BACKEND; await audioControls.switchBackend("mpv").catch(() => {}); } actions.push(newAction); saveActions(actions); await execAction(setup, newAction); } // Final settle + capture. await setup.renderOnce(); await new Promise((r) => setTimeout(r, 60)); const spans = setup.captureSpans() as { lines: { spans: Span[] }[]; cols: number; rows: number; cursor: [number, number]; }; const plainFrame = setup.captureCharFrame(); // Dump structured spans + plain frame. try { writeFileSync(FRAME_JSON, JSON.stringify(spans)); writeFileSync(FRAME_TXT, plainFrame); } catch {} // Store state snapshot. const state: Record = {}; try { const nav = navRef; if (nav) { state.nav = { tab: nav.activeTab?.(), pane: nav.activePane?.(), mode: nav.mode?.(), input: nav.inputFocused?.(), sel: nav.selectedIds?.()?.length ?? 0, ready: nav.ready, }; } else { state.nav = "NAV_REF not captured (probe did not run)"; } } catch (e) { state.nav = "ERR: " + String(e); } try { if (audioControls) { state.audio = { backend: audioControls.backendName ? audioControls.backendName() : null, playing: audioControls.isPlaying ? audioControls.isPlaying() : null, pos: audioControls.position ? audioControls.position() : null, dur: audioControls.duration ? audioControls.duration() : null, vol: audioControls.volume ? audioControls.volume() : null, err: audioControls.error ? audioControls.error() : null, ep: audioControls.currentEpisode ? audioControls.currentEpisode()?.title : null, }; } } catch (e) { state.audio = "ERR: " + String(e); } try { const { useFeedStore } = await import("../src/stores/feed"); const fs_ = useFeedStore(); const feeds = fs_.feeds ? fs_.feeds() : []; state.feed = { count: feeds?.length ?? 0, sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null, loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null, titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title), }; } catch (e) { state.feed = "ERR: " + String(e); } try { writeFileSync(STATE_JSON, JSON.stringify(state)); } catch {} // ── Output ────────────────────────────────────────────────────────────── // Compact by default: trimmed frame, one-line state per section, no styles // block, no boilerplate footer. Use --styles / --verbose to opt back in. const verbose = !!flags.verbose; const scope = cmd === "state" ? String(positional[0] || "all") : "all"; // A line is "visually empty" if it's either fully blank OR contains only // box-drawing chars + whitespace (i.e. empty-pane interior padding like // "│ │"). Runs of these collapse to a single `…N` marker so an empty // 24-row pane costs 1 line, not 18. const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼"; const isVisuallyEmpty = (l: string): boolean => l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch)); const frameTrimmed = (() => { const lines = plainFrame .replace(/\n+$/, "") .split("\n") .map((l) => l.replace(/\s+$/, "")); while (lines.length && isVisuallyEmpty(lines[lines.length - 1])) lines.pop(); const out: string[] = []; let blank = 0; const flushBlanks = () => { if (blank >= 3) out.push(` …${blank} empty`); else for (let i = 0; i < blank; i++) out.push(""); blank = 0; }; for (const l of lines) { if (isVisuallyEmpty(l)) { blank++; } else { flushBlanks(); out.push(l); } } flushBlanks(); return out.join("\n"); })(); console.log( `FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`, ); console.log(frameTrimmed); // ── distinct styles: opt-in only (--styles OR --verbose) ── if (scope === "all" && (flags.styles || verbose)) { const styles = distinctStyles(spans); if (styles.length) { console.log("-- styles (top 20) --"); for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`); } } // ── state: one compact line per requested section ── const want = (k: string) => scope === "all" || scope === k; const compact = (obj: unknown): string => verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj); if (want("nav")) console.log("nav " + compact(state.nav)); if (want("audio")) console.log("audio " + compact(state.audio)); if (want("feed")) console.log("feed " + compact(state.feed)); if (want("app")) console.log("app (not dumped in v1)"); // ── issues: terse ── if (issues.length) { console.log(`issues:${issues.length}`); for (const i of issues.slice(0, 20)) console.log(" ! " + i); } else { console.log("issues:none"); } // Footer is identical every run — only print on init or --verbose. if (cmd === "init" || verbose) { console.log( `(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`, ); } // Tear down child processes (audio backend) before exit to avoid orphans. try { if (audioControls?.stop) await audioControls.stop().catch(() => {}); } catch (e) { issues.push("teardown audio: " + String(e)); } try { setup.renderer.destroy(); } catch (e) { issues.push("teardown renderer: " + String(e)); } process.exit(0); } main().catch((err) => { console.error("HARNESS FAILED:", err?.stack || err); process.exit(1); });