620 lines
20 KiB
TypeScript
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);
|
|
});
|
|
}
|