#!/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 { throw new Error(`unknown flag: ${a}`); } } 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; } // Per-command builders. Leading positional tokens that name a modifier // (ctrl/shift/...) are stripped as mods; the rest is the command's data. const modsOrUndefined = (positional: string[]): Mod[] | undefined => { const mods = parseMods(positional); return mods.length ? mods : undefined; }; const BUILDERS: Record Action> = { key: (p) => { if (!p[0]) throw new Error("key requires a argument"); return { t: "key", k: p[0], mods: modsOrUndefined(p) }; }, arrow: (p) => { if (!p[0] || !["up", "down", "left", "right"].includes(p[0])) throw new Error("arrow requires up|down|left|right"); return { t: "arrow", d: p[0] as "up" | "down" | "left" | "right", mods: modsOrUndefined(p), }; }, enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }), escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }), tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }), space: (p) => ({ t: "space", mods: modsOrUndefined(p) }), backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }), type: (p) => { if (p[0] === undefined) throw new Error("type requires "); // Re-join the rest in case text had spaces; p[0] already is first token, // caller should quote. We join all positional as the text. return { t: "type", s: p.join(" ") }; }, wait: (p) => { if (!p[0]) throw new Error("wait requires "); return { t: "wait", ms: parseInt(p[0], 10) || 0 }; }, resize: (p) => { if (!p[0] || !p[1]) throw new Error("resize requires "); return { t: "resize", w: parseInt(p[0], 10) || 100, h: parseInt(p[1], 10) || 30, }; }, }; function buildAction(cmd: string, positional: string[]): Action | null { const builder = BUILDERS[cmd]; if (builder) return builder(positional); // Local-only commands return early in main before this is reached; keep // the null contract so the public behavior is unchanged. if ( cmd === "frame" || cmd === "state" || cmd === "reset" || cmd === "actions" || cmd === "init" || cmd === "seed" ) return null; // Single table-miss error for any unknown command. 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)); } // ── Mount, snapshot & output (extracted from main) ───────────────────────── // 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)); function trimFrame(plainFrame: string): string { 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"); } // Local-only commands that don't mount. Returns true if handled (main returns). function runLocal(cmd: string, flags: Record): boolean { if (cmd === "reset") { saveActions([]); console.log("✔ actions log cleared."); return true; } if (cmd === "actions") { const a = loadActions(); console.log(`Action log (${a.length}):`); console.log(JSON.stringify(a, null, 2)); return true; } 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 true; } return false; } type FrameCapture = { lines: { spans: Span[] }[]; cols: number; rows: number; cursor: [number, number]; }; async function mountApp( flags: Record, cmd: string, positional: string[], ): Promise<{ setup: any; spans: FrameCapture; plainFrame: string; audioControls: any; actions: Action[]; }> { // 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 FrameCapture; const plainFrame = setup.captureCharFrame(); // Dump structured spans + plain frame. try { writeFileSync(FRAME_JSON, JSON.stringify(spans)); writeFileSync(FRAME_TXT, plainFrame); } catch {} return { setup, spans, plainFrame, audioControls, actions }; } async function snapshotState(audioControls: any): Promise> { // 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 {} return state; } function emitOutput(p: { spans: FrameCapture; plainFrame: string; state: Record; actions: Action[]; cmd: string; flags: Record; positional: string[]; }): void { // 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 = !!p.flags.verbose; const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all"; console.log( `FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`, ); console.log(trimFrame(p.plainFrame)); // ── distinct styles: opt-in only (--styles OR --verbose) ── if (scope === "all" && (p.flags.styles || verbose)) { const styles = distinctStyles(p.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(p.state.nav)); if (want("audio")) console.log("audio " + compact(p.state.audio)); if (want("feed")) console.log("feed " + compact(p.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 (p.cmd === "init" || verbose) { console.log( `(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`, ); } } async function teardown(setup: any, audioControls: any): Promise { // 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 ─────────────────────────────────────────────────────────────────── 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 (runLocal(cmd, flags)) return; const m = await mountApp(flags, cmd, positional); const state = await snapshotState(m.audioControls); emitOutput({ spans: m.spans, plainFrame: m.plainFrame, state, actions: m.actions, cmd, flags, positional, }); await teardown(m.setup, m.audioControls); } main().catch((err) => { console.error("HARNESS FAILED:", err?.stack || err); process.exit(1); });