// ABOUTME: VHS-based debug capture tool that screenshots Pi's TUI for visual inspection. // ABOUTME: Registers /debug-capture command and debug_capture tool to generate PNGs the agent can Read. /** * Debug Capture — Visual TUI debugging via charmbracelet/vhs * * Generates VHS .tape files, runs them to produce PNG screenshots, * and returns paths so the agent can `Read` the images to see what * the user sees. Bridges the gap between code-level understanding * and visual rendering. * * Commands: * /debug-capture — capture a predefined or custom scenario * * Tool: * debug_capture — programmatic capture (agent can call during work) * * Scenarios: * tasks — Pi with sample task list widget * modes — Each operational mode screenshot * footer — Footer status bar * theme — Pi with a specific theme * custom — Arbitrary shell commands * pi — Run Pi with a prompt and capture its output * * Prerequisites: vhs, ttyd, ffmpeg on PATH * * Usage: pi -e extensions/debug-capture.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { type AutocompleteItem } from "@mariozechner/pi-tui"; import { execSync, spawn } from "child_process"; import { existsSync, mkdirSync, writeFileSync, readdirSync } from "fs"; import { join, dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { Text } from "@mariozechner/pi-tui"; // ── Constants ──────────────────────────────────── const CAPTURE_DIR_NAME = "debug-captures"; const DEFAULT_WIDTH = 1400; const DEFAULT_HEIGHT = 900; const DEFAULT_FONT_SIZE = 13; const DEFAULT_THEME = "Dracula"; const VHS_WAIT_TIMEOUT = "30s"; // ── Types ──────────────────────────────────────── interface CaptureOptions { width?: number; height?: number; fontSize?: number; theme?: string; waitPattern?: string; waitTimeout?: string; } interface CaptureResult { screenshots: string[]; gif?: string; error?: string; tapePath: string; elapsed: number; } // ── Tape Generation ────────────────────────────── function timestamp(): string { const now = new Date(); const pad = (n: number, len = 2) => String(n).padStart(len, "0"); return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; } function tapeHeader(captureDir: string, ts: string, opts: CaptureOptions): string { const w = opts.width ?? DEFAULT_WIDTH; const h = opts.height ?? DEFAULT_HEIGHT; const fs = opts.fontSize ?? DEFAULT_FONT_SIZE; const theme = opts.theme ?? DEFAULT_THEME; return [ `Output ${captureDir}/capture-${ts}.gif`, "", `Set Shell "bash"`, `Set FontSize ${fs}`, `Set Width ${w}`, `Set Height ${h}`, `Set Theme "${theme}"`, `Set TypingSpeed 20ms`, "", ].join("\n"); } function screenshotCmd(captureDir: string, name: string): string { return `Screenshot ${captureDir}/${name}.png`; } function waitForScreen(pattern: string, timeout?: string): string { const t = timeout ?? VHS_WAIT_TIMEOUT; return `Wait+Screen@${t} /${pattern}/`; } // ── Scenario Generators ────────────────────────── /** * Write a helper bash script to the capture dir and return its relative path. * This avoids typing long ANSI-laden echo commands into VHS which garbles output. */ function writeHelperScript(captureDir: string, absCaptureDir: string, name: string, scriptContent: string): string { const relPath = `${captureDir}/${name}.sh`; const absPath = join(absCaptureDir, `${name}.sh`); writeFileSync(absPath, scriptContent, { mode: 0o755 }); return relPath; } function scenarioCustom(commands: string, captureDir: string, ts: string, opts: CaptureOptions): string { const lines = [tapeHeader(captureDir, ts, opts)]; // Split commands by semicolons or newlines const cmds = commands.split(/[;\n]/).map(c => c.trim()).filter(Boolean); for (const cmd of cmds) { lines.push(`Type "${cmd.replace(/"/g, '\\"')}"`); lines.push("Enter"); lines.push("Sleep 1s"); } lines.push("Sleep 2s"); lines.push(screenshotCmd(captureDir, `custom-${ts}`)); lines.push("Sleep 500ms"); return lines.join("\n"); } function scenarioPi(prompt: string, captureDir: string, ts: string, opts: CaptureOptions): string { const extDir = dirname(fileURLToPath(import.meta.url)); const lines = [tapeHeader(captureDir, ts, opts)]; // Run Pi in print mode with the prompt const escaped = prompt.replace(/"/g, '\\"').replace(/'/g, "'\\''"); lines.push(`Type "pi -p '${escaped}'"`); lines.push("Enter"); lines.push(""); lines.push("# Wait for Pi to finish (look for shell prompt return)"); lines.push(`Sleep 15s`); lines.push(""); lines.push(screenshotCmd(captureDir, `pi-output-${ts}`)); lines.push("Sleep 500ms"); return lines.join("\n"); } function scenarioTasks(captureDir: string, ts: string, opts: CaptureOptions, absCaptureDir: string): string { const lines = [tapeHeader(captureDir, ts, opts)]; // Write a helper script that renders the task list with proper ANSI colors const script = `#!/bin/bash # Simulated Pi task list widget BG="\\033[48;5;236m" RST="\\033[0m" ACCENT="\\033[38;5;117m" BOLD="\\033[1m" MUTED="\\033[38;5;245m" SUCCESS="\\033[38;5;78m" DIM="\\033[38;5;243m" echo "" echo -e "\${BG} \${RST}" echo -e "\${BG} \${ACCENT}\${BOLD}Tasks 2/5\${RST}\${BG} \${RST}" echo -e "\${BG} \${MUTED}- \${ACCENT}1\${RST}\${BG} \${MUTED}Investigate VHS tool\${RST}\${BG} \${RST}" echo -e "\${BG} \${SUCCESS}* \${ACCENT}2\${RST}\${BG} \${SUCCESS}Build debug-capture extension\${RST}\${BG} \${RST}" echo -e "\${BG} \${MUTED}- \${ACCENT}3\${RST}\${BG} \${MUTED}Write tests\${RST}\${BG} \${RST}" echo -e "\${BG} \${SUCCESS}x \${ACCENT}4\${RST}\${BG} \${DIM}Research VHS capabilities\${RST}\${BG} \${RST}" echo -e "\${BG} \${SUCCESS}x \${ACCENT}5\${RST}\${BG} \${DIM}Design architecture\${RST}\${BG} \${RST}" echo -e "\${BG} \${RST}" echo "" `; const scriptPath = writeHelperScript(captureDir, absCaptureDir, `tasks-${ts}`, script); lines.push("Hide"); lines.push(`Type "clear && bash ${scriptPath}"`); lines.push("Enter"); lines.push("Show"); lines.push("Sleep 1s"); lines.push(screenshotCmd(captureDir, `tasks-${ts}`)); lines.push("Sleep 500ms"); return lines.join("\n"); } function scenarioModes(captureDir: string, ts: string, opts: CaptureOptions, absCaptureDir: string): string { const modes = ["NORMAL", "PLAN", "SPEC", "PIPELINE", "TEAM", "CHAIN"]; const lines = [tapeHeader(captureDir, ts, opts)]; // Write a helper script that shows all mode banners const script = `#!/bin/bash BG_BLUE="\\033[44m" FG_WHITE="\\033[1;97m" RST="\\033[0m" PAD=" " echo "" echo -e "\${FG_WHITE}Mode: NORMAL (no banner)\${RST}" echo "" echo -e "\${BG_BLUE}\${FG_WHITE} PLAN \${PAD}\${RST}" echo "" echo -e "\${BG_BLUE}\${FG_WHITE} SPEC \${PAD}\${RST}" echo "" echo -e "\${BG_BLUE}\${FG_WHITE} PIPELINE \${PAD}\${RST}" echo "" echo -e "\${BG_BLUE}\${FG_WHITE} TEAM \${PAD}\${RST}" echo "" echo -e "\${BG_BLUE}\${FG_WHITE} CHAIN \${PAD}\${RST}" echo "" `; const scriptPath = writeHelperScript(captureDir, absCaptureDir, `modes-${ts}`, script); lines.push("Hide"); lines.push(`Type "clear && bash ${scriptPath}"`); lines.push("Enter"); lines.push("Show"); lines.push("Sleep 1s"); lines.push(screenshotCmd(captureDir, `modes-${ts}`)); lines.push("Sleep 500ms"); return lines.join("\n"); } function scenarioFooter(captureDir: string, ts: string, opts: CaptureOptions, absCaptureDir: string): string { const lines = [tapeHeader(captureDir, ts, opts)]; // Write a helper script that renders a footer bar const script = `#!/bin/bash DIM="\\033[90m" ACCENT="\\033[38;5;117m\\033[1m" RST="\\033[0m" clear echo "" echo -e " \${ACCENT}opus 4\${RST} \${DIM}|\${RST} \${DIM}42%\${RST} \${DIM}|\${RST} \${DIM}Github-Work/pi-agent\${RST}" `; const scriptPath = writeHelperScript(captureDir, absCaptureDir, `footer-${ts}`, script); lines.push("Hide"); lines.push(`Type "clear && bash ${scriptPath}"`); lines.push("Enter"); lines.push("Show"); lines.push("Sleep 1s"); lines.push(screenshotCmd(captureDir, `footer-${ts}`)); lines.push("Sleep 500ms"); return lines.join("\n"); } function scenarioTheme(themeName: string, captureDir: string, ts: string, opts: CaptureOptions, absCaptureDir: string): string { const themedOpts = { ...opts, theme: themeName }; const lines = [tapeHeader(captureDir, ts, themedOpts)]; const safeName = themeName.toLowerCase().replace(/\s+/g, "-"); // Write a helper script that shows colorful output const script = `#!/bin/bash echo "" echo "Theme: ${themeName}" echo "" echo -e "\\033[31mRed \\033[32mGreen \\033[33mYellow \\033[34mBlue \\033[35mMagenta \\033[36mCyan \\033[37mWhite\\033[0m" echo -e "\\033[1;31mBold Red \\033[1;32mBold Green \\033[1;34mBold Blue \\033[1;36mBold Cyan\\033[0m" echo -e "\\033[90mDim text \\033[0m| \\033[4mUnderlined\\033[0m | \\033[7mInverse\\033[0m" echo "" ls --color=auto `; const scriptPath = writeHelperScript(captureDir, absCaptureDir, `theme-${safeName}-${ts}`, script); lines.push("Hide"); lines.push(`Type "clear && bash ${scriptPath}"`); lines.push("Enter"); lines.push("Show"); lines.push("Sleep 2s"); lines.push(screenshotCmd(captureDir, `theme-${safeName}-${ts}`)); lines.push("Sleep 500ms"); return lines.join("\n"); } // ── VHS Runner ─────────────────────────────────── function ensureCaptureDir(cwd: string): string { const captureDir = join(cwd, ".pi", CAPTURE_DIR_NAME); if (!existsSync(captureDir)) { mkdirSync(captureDir, { recursive: true }); } return captureDir; } function runVhs(tapePath: string, cwd: string, ts: string): Promise { const startTime = Date.now(); return new Promise((resolve) => { const proc = spawn("vhs", [tapePath], { cwd, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; proc.stdout!.setEncoding("utf-8"); proc.stdout!.on("data", (chunk: string) => { stdout += chunk; }); proc.stderr!.setEncoding("utf-8"); proc.stderr!.on("data", (chunk: string) => { stderr += chunk; }); proc.on("close", (code) => { const elapsed = Date.now() - startTime; if (code !== 0) { resolve({ screenshots: [], tapePath, elapsed, error: `VHS exited with code ${code}:\n${stderr || stdout}`, }); return; } // Find PNGs and GIFs from THIS run only (matched by timestamp) const captureDir = join(cwd, ".pi", CAPTURE_DIR_NAME); const screenshots: string[] = []; let gif: string | undefined; try { const files = readdirSync(captureDir) as string[]; for (const f of files) { if (!f.includes(ts)) continue; // Only this run's files const fullPath = join(captureDir, f); if (f.endsWith(".png")) screenshots.push(fullPath); if (f.endsWith(".gif")) gif = fullPath; } screenshots.sort(); } catch {} resolve({ screenshots, gif, tapePath, elapsed }); }); proc.on("error", (err) => { resolve({ screenshots: [], tapePath, elapsed: Date.now() - startTime, error: `Failed to spawn VHS: ${err.message}`, }); }); }); } // ── Scenario Router ────────────────────────────── function generateTape( scenario: string, cwd: string, opts: CaptureOptions = {}, ): { tape: string; tapePath: string; captureDir: string; ts: string } { const captureDir = ensureCaptureDir(cwd); // Use relative path from cwd for VHS (it doesn't like absolute paths) const relCaptureDir = ".pi/" + CAPTURE_DIR_NAME; const ts = timestamp(); const parts = scenario.trim().split(/\s+/); const command = parts[0]?.toLowerCase() || "custom"; const args = parts.slice(1).join(" "); let tape: string; switch (command) { case "tasks": tape = scenarioTasks(relCaptureDir, ts, opts, captureDir); break; case "modes": tape = scenarioModes(relCaptureDir, ts, opts, captureDir); break; case "footer": tape = scenarioFooter(relCaptureDir, ts, opts, captureDir); break; case "theme": tape = scenarioTheme(args || DEFAULT_THEME, relCaptureDir, ts, opts, captureDir); break; case "pi": tape = scenarioPi(args || "Say hello", relCaptureDir, ts, opts); break; case "custom": tape = scenarioCustom(args || "echo 'No commands specified'", relCaptureDir, ts, opts); break; default: // Treat the entire input as custom commands tape = scenarioCustom(scenario, relCaptureDir, ts, opts); break; } const tapePath = join(captureDir, `tape-${ts}.tape`); writeFileSync(tapePath, tape, "utf-8"); return { tape, tapePath, captureDir, ts }; } // ── Format Results ─────────────────────────────── function formatResult(result: CaptureResult): string { const lines: string[] = []; if (result.error) { lines.push(`Error: ${result.error}`); lines.push(`Tape file: ${result.tapePath}`); return lines.join("\n"); } lines.push(`Capture complete in ${Math.round(result.elapsed / 1000)}s`); lines.push(""); if (result.screenshots.length > 0) { lines.push(`Screenshots (${result.screenshots.length}):`); for (const path of result.screenshots) { lines.push(` ${path}`); } lines.push(""); lines.push("Use Read on any screenshot path above to view the captured UI."); } else { lines.push("No screenshots were generated."); } if (result.gif) { lines.push(""); lines.push(`GIF: ${result.gif}`); } lines.push(""); lines.push(`Tape: ${result.tapePath}`); return lines.join("\n"); } // ── Extension ──────────────────────────────────── export default function (pi: ExtensionAPI) { // ── Check prerequisites on load ────────────── function checkPrereqs(): string | null { try { execSync("which vhs", { stdio: "ignore" }); execSync("which ttyd", { stdio: "ignore" }); execSync("which ffmpeg", { stdio: "ignore" }); return null; } catch { return "Missing prerequisites: vhs, ttyd, and ffmpeg must be on PATH. Install with: brew install vhs"; } } // ── /debug-capture command ─────────────────── const SCENARIOS = ["tasks", "modes", "footer", "theme", "pi", "custom"]; pi.registerCommand("debug-capture", { description: "Capture a VHS screenshot of Pi's TUI for visual debugging", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const items = SCENARIOS.map(s => ({ value: s, label: s === "tasks" ? "tasks — Task list widget with sample data" : s === "modes" ? "modes — Each operational mode banner" : s === "footer" ? "footer — Footer status bar" : s === "theme" ? "theme — Pi with a specific VHS theme" : s === "pi" ? "pi — Run Pi with a prompt and capture output" : "custom — Run arbitrary shell commands", })); const filtered = items.filter(i => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : items; }, handler: async (args, ctx) => { const scenario = args?.trim(); if (!scenario) { ctx.ui.notify( "Usage: /debug-capture \n" + "Scenarios: tasks, modes, footer, theme , pi , custom ", "warning", ); return; } const prereqError = checkPrereqs(); if (prereqError) { ctx.ui.notify(prereqError, "error"); return; } ctx.ui.notify(`Capturing: ${scenario}...`, "info"); const { tapePath, ts } = generateTape(scenario, ctx.cwd); const result = await runVhs(tapePath, ctx.cwd, ts); if (result.error) { ctx.ui.notify(`Capture failed: ${result.error}`, "error"); } else { const count = result.screenshots.length; ctx.ui.notify( `Captured ${count} screenshot${count !== 1 ? "s" : ""} in ${Math.round(result.elapsed / 1000)}s. ` + `Use Read on the paths to inspect.`, "success", ); } // Print full result to chat return formatResult(result); }, }); // ── debug_capture tool ─────────────────────── pi.registerTool({ name: "debug_capture", label: "Debug Capture", description: [ "Capture a VHS screenshot of the terminal UI for visual debugging.", "Returns paths to PNG screenshots that can be viewed with the Read tool.", "", "Scenarios:", " tasks — Task list widget with sample data", " modes — Each operational mode banner", " footer — Footer status bar", " theme — Terminal with a specific VHS theme (e.g. 'theme Dracula')", " pi — Run Pi non-interactively and capture its output", " custom — Run arbitrary shell commands (semicolon-separated)", "", "The resulting PNG paths can be passed to the Read tool to visually inspect the UI.", ].join("\n"), parameters: Type.Object({ scenario: Type.String({ description: "Capture scenario: tasks, modes, footer, theme , pi , custom ", }), width: Type.Optional(Type.Number({ description: "Terminal width in pixels (default: 1400)" })), height: Type.Optional(Type.Number({ description: "Terminal height in pixels (default: 900)" })), fontSize: Type.Optional(Type.Number({ description: "Font size in pixels (default: 13)" })), theme: Type.Optional(Type.String({ description: "VHS terminal theme (default: Dracula)" })), }), async execute(_toolCallId, params, _signal, onUpdate, ctx) { const { scenario, width, height, fontSize, theme } = params as { scenario: string; width?: number; height?: number; fontSize?: number; theme?: string }; const prereqError = checkPrereqs(); if (prereqError) { return { content: [{ type: "text", text: prereqError }], details: { error: prereqError }, }; } if (onUpdate) { onUpdate({ content: [{ type: "text", text: `Capturing: ${scenario}...` }], details: { scenario, status: "running" }, }); } const opts: CaptureOptions = { width, height, fontSize, theme }; const { tapePath, ts } = generateTape(scenario, ctx.cwd, opts); const result = await runVhs(tapePath, ctx.cwd, ts); const output = formatResult(result); return { content: [{ type: "text", text: output }], details: { scenario, status: result.error ? "error" : "done", screenshots: result.screenshots, gif: result.gif, tapePath: result.tapePath, elapsed: result.elapsed, }, }; }, renderCall(_params, _theme) { const p = _params as { scenario: string }; const DIM = "\x1b[90m"; const BRIGHT = "\x1b[1;97m"; const RST = "\x1b[0m"; return new Text(`${DIM}debug-capture:${RST} ${BRIGHT}${p.scenario}${RST}`, 0, 0); }, renderResult(result, _options, _theme) { const details = result.details as any; const DIM = "\x1b[90m"; const GREEN = "\x1b[32m"; const RED = "\x1b[91m"; const BRIGHT = "\x1b[1;97m"; const RST = "\x1b[0m"; if (details?.error) { return new Text(`${RED}capture failed${RST}`, 0, 0); } const count = details?.screenshots?.length ?? 0; const elapsed = details?.elapsed ? Math.round(details.elapsed / 1000) : 0; return new Text( `${GREEN}captured${RST} ${BRIGHT}${count}${RST} ${DIM}screenshot${count !== 1 ? "s" : ""} in ${elapsed}s${RST}`, 0, 0, ); }, }); // ── Session start ──────────────────────────── pi.on("session_start", async (_event, ctx) => { // Ensure capture directory exists ensureCaptureDir(ctx.cwd); }); }