Files
pi-skill/extensions/debug-capture.ts
2026-05-25 16:41:08 +07:00

620 lines
20 KiB
TypeScript

// 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 <scenario> — 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 <name> — Pi with a specific theme
* custom <cmds> — Arbitrary shell commands
* pi <prompt> — 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<CaptureResult> {
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 <name> — Pi with a specific VHS theme"
: s === "pi" ? "pi <prompt> — Run Pi with a prompt and capture output"
: "custom <cmds> — 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 <scenario>\n" +
"Scenarios: tasks, modes, footer, theme <name>, pi <prompt>, custom <cmds>",
"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 <name> — Terminal with a specific VHS theme (e.g. 'theme Dracula')",
" pi <prompt> — Run Pi non-interactively and capture its output",
" custom <cmds> — 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 <name>, pi <prompt>, custom <cmds>",
}),
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);
});
}