166 lines · 5.7 KB · TypeScript
RawDownload
// The chat renderer half of the chat theater (see chat-theater.ts): a tiny// agent-chat TUI that runs inside the recorded PTY and paints whatever the// scenario's REAL MCP calls are doing. It performs no logic of its own — it// reads base64-encoded JSON events on stdin and renders them with the// pacing of a chat session (typed user input, streamed agent text, live// tool spinners that run exactly as long as the real call did).//// Run with: bun agent-chat-tui.ts "<session title>" [events-fifo]//// Two transports: events arrive base64-encoded one-per-line either on stdin// (PTY mode — the chat theater types them into the recorded terminal) or on// a FIFO path (desk mode — the renderer runs inside a visible xterm on the// virtual desktop, where stdin belongs to the terminal emulator).import { createReadStream, writeFileSync } from "node:fs";import { createInterface } from "node:readline"; type TheaterEvent =  | { readonly type: "user"; readonly text: string }  | { readonly type: "assistant"; readonly text: string }  | {      readonly type: "tool-start";      readonly name: string;      /** The call's real input, pre-rendered as preview lines. */      readonly input?: readonly string[];    }  | {      readonly type: "tool-end";      readonly ok: boolean;      /** First line of the call's real result. */      readonly result?: string;      readonly seconds?: number;    }  | { readonly type: "status"; readonly text: string }  | { readonly type: "done" }; const out = (text: string) => process.stdout.write(text); // The PTY would otherwise echo every incoming event line into the recording.if (process.stdin.isTTY) process.stdin.setRawMode(true); const DIM = "\x1b[2m";const BOLD = "\x1b[1m";const RESET = "\x1b[0m";const CYAN = "\x1b[36m";const GREEN = "\x1b[32m";const RED = "\x1b[31m";const MAGENTA = "\x1b[35m";const CLEAR_LINE = "\x1b[2K\r"; const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const sleep = (ms: number) => new Promise<void>((tick) => setTimeout(tick, ms)); const title = process.argv[2] ?? "executor session";const eventsFifo = process.argv[3];out(`${BOLD}${MAGENTA}●${RESET} ${BOLD}${title}${RESET}\n`);out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`); let spinnerTimer: ReturnType<typeof setInterval> | undefined;let spinnerName = ""; // A tool call renders the way agent TUIs render them: the tool's name, the// call's real input as an indented block, a spinner while the real call// runs, then the real result's first line with the real duration.////   ⚙ execute//   │ const added = await tools.executor.openapi.addSpec({//   │   spec: { kind: "url", url: "https://…/openapi.json" },//   ⠼ running…//   ✓ 2.3s  {"ok":true,"slug":"resend","toolCount":9}const startTool = (name: string, input?: readonly string[]) => {  spinnerName = name;  out(`\n  ${MAGENTA}⚙${RESET} ${BOLD}${name}${RESET}\n`);  for (const line of input ?? []) {    out(`  ${DIM}│ ${line.slice(0, 92)}${RESET}\n`);  }  let frame = 0;  spinnerTimer = setInterval(() => {    out(`${CLEAR_LINE}  ${CYAN}${SPINNER[frame % SPINNER.length]}${RESET} ${DIM}running…${RESET}`);    frame += 1;  }, 80);}; const endTool = (ok: boolean, result?: string, seconds?: number) => {  if (spinnerTimer) clearInterval(spinnerTimer);  spinnerTimer = undefined;  const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗ ${spinnerName}${RESET}`;  const took = seconds !== undefined ? `${DIM}${seconds.toFixed(1)}s${RESET}  ` : "";  out(`${CLEAR_LINE}  ${mark} ${took}${DIM}${(result ?? "").slice(0, 88)}${RESET}\n`);}; const handle = async (event: TheaterEvent): Promise<boolean> => {  switch (event.type) {    case "user": {      out(`\n${BOLD}${CYAN}┃ you${RESET}  `);      for (const ch of event.text) {        out(ch);        await sleep(18);      }      out("\n");      return true;    }    case "assistant": {      out(`\n${BOLD}${MAGENTA}● agent${RESET}  `);      for (const piece of event.text.match(/.{1,3}/gs) ?? []) {        out(piece);        await sleep(12);      }      out("\n");      return true;    }    case "tool-start": {      startTool(event.name, event.input);      return true;    }    case "tool-end": {      endTool(event.ok, event.result, event.seconds);      return true;    }    case "status": {      out(`\n  ${DIM}· ${event.text}${RESET}\n`);      return true;    }    case "done": {      out(`\n${DIM}${"─".repeat(40)}${RESET}\n${GREEN}✦ session complete${RESET}\n`);      if (eventsFifo) {        // Desk mode: ack completion to the driver, then linger so the        // closing frame stays on camera before the xterm vanishes.        writeFileSync(`${eventsFifo}.done`, "");        await sleep(4_000);      }      return false;    }  }}; // Events arrive faster than they render; process strictly in order so the// animations (not arrival time) set the pacing.const queue: TheaterEvent[] = [];let draining = false;const drain = async () => {  if (draining) return;  draining = true;  while (queue.length > 0) {    const event = queue.shift();    if (!event) break;    const keepGoing = await handle(event);    if (!keepGoing) {      process.exit(0);    }  }  draining = false;}; const input = eventsFifo ? createReadStream(eventsFifo) : process.stdin;createInterface({ input, terminal: false }).on("line", (line) => {  const trimmed = line.trim();  if (!trimmed) return;  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: PTY stdin may echo stray keystrokes that aren't base64 JSON; ignore them  try {    queue.push(JSON.parse(Buffer.from(trimmed, "base64").toString("utf8")) as TheaterEvent);  } catch {    return;  }  void drain();});