/** * RPC Extension UI Example (TUI) * * A lightweight TUI chat client that spawns the agent in RPC mode. * Demonstrates how to build a custom UI on top of the RPC protocol, * including handling extension UI requests (select, confirm, input, editor). * * Usage: bun examples/rpc-extension-ui.ts * * Slash commands: * /select - demo select dialog * /confirm - demo confirm dialog * /input - demo input dialog * /editor - demo editor dialog */ import { spawn } from "node:child_process"; import { dirname, join } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { fileURLToPath } from "node:url"; import { type Component, Container, ProcessTerminal, TUI } from "@earendil-works/pi-tui"; const __dirname = dirname(fileURLToPath(import.meta.url)); // ============================================================================ // ANSI helpers // ============================================================================ const GREEN = "\x1b[32m"; const YELLOW = "\x1b[33m"; const BLUE = "\x1b[34m"; const MAGENTA = "\x1b[35m"; const RED = "\x1b[31m"; const DIM = "\x1b[2m"; const BOLD = "\x1b[1m"; const RESET = "\x1b[0m"; // ============================================================================ // Extension UI request type (subset of rpc-types.ts) // ============================================================================ interface ExtensionUIRequest { type: "extension_ui_request"; id: string; method: string; title?: string; options?: string[]; message?: string; placeholder?: string; prefill?: string; notifyType?: "info" | "warning" | "error"; statusKey?: string; statusText?: string; widgetKey?: string; widgetLines?: string[]; text?: string; } // ============================================================================ // Output log: accumulates styled lines, renders the tail that fits // ============================================================================ import { InputDialog, LoadingIndicator, OutputLog, PromptInput, SelectDialog } from "./rpc-extension-ui-components.js"; // ============================================================================ // Main // ============================================================================ async function main() { const extensionPath = join(__dirname, "extensions/rpc-demo.ts"); const cliPath = join(__dirname, "../dist/cli.js"); const agent = spawn( "node", [cliPath, "--mode", "rpc", "--no-session", "--no-extensions", "--extension", extensionPath], { stdio: ["pipe", "pipe", "pipe"] }, ); let stderr = ""; agent.stderr?.on("data", (data: Buffer) => { stderr += data.toString(); }); await new Promise((resolve) => setTimeout(resolve, 500)); if (agent.exitCode !== null) { console.error(`Agent exited immediately. Stderr:\n${stderr}`); process.exit(1); } // -- TUI setup -- const terminal = new ProcessTerminal(); const tui = new TUI(terminal); const outputLog = new OutputLog(); const loadingIndicator = new LoadingIndicator(); const promptInput = new PromptInput(); const root = new Container(); root.addChild(outputLog); root.addChild(promptInput); tui.addChild(root); tui.setFocus(promptInput.input); // -- Agent communication -- function send(obj: Record): void { agent.stdin!.write(`${JSON.stringify(obj)}\n`); } let isStreaming = false; let hasTextOutput = false; function exit(): void { tui.stop(); agent.kill("SIGTERM"); process.exit(0); } // -- Bottom area management -- // The bottom of the screen is either the prompt input or a dialog. // These helpers swap between them. let activeDialog: Component | null = null; function setBottomComponent(component: Component): void { root.clear(); root.addChild(outputLog); if (isStreaming) root.addChild(loadingIndicator); root.addChild(component); tui.setFocus(component); tui.requestRender(); } function showPrompt(): void { activeDialog = null; setBottomComponent(promptInput); tui.setFocus(promptInput.input); } function showDialog(dialog: Component): void { activeDialog = dialog; setBottomComponent(dialog); } function showLoading(): void { if (!isStreaming) { isStreaming = true; hasTextOutput = false; root.clear(); root.addChild(outputLog); root.addChild(loadingIndicator); root.addChild(activeDialog ?? promptInput); if (!activeDialog) tui.setFocus(promptInput.input); loadingIndicator.start(tui); tui.requestRender(); } } function hideLoading(): void { loadingIndicator.stop(); root.clear(); root.addChild(outputLog); root.addChild(activeDialog ?? promptInput); if (!activeDialog) tui.setFocus(promptInput.input); tui.requestRender(); } // -- Extension UI dialog handling -- function showSelectDialog(title: string, options: string[], onDone: (value: string | undefined) => void): void { const dialog = new SelectDialog(title, options); dialog.onSelect = (value) => { showPrompt(); onDone(value); }; dialog.onCancel = () => { showPrompt(); onDone(undefined); }; showDialog(dialog); } function showInputDialog(title: string, prefill?: string, onDone?: (value: string | undefined) => void): void { const dialog = new InputDialog(title, prefill); dialog.onSubmit = (value) => { showPrompt(); onDone?.(value.trim() || undefined); }; dialog.onEscape = () => { showPrompt(); onDone?.(undefined); }; dialog.onCtrlD = exit; showDialog(dialog); tui.setFocus(dialog.inputComponent); } function handleExtensionUI(req: ExtensionUIRequest): void { const { id, method } = req; switch (method) { // Dialog methods: replace prompt with interactive component case "select": { showSelectDialog(req.title ?? "Select", req.options ?? [], (value) => { if (value !== undefined) { send({ type: "extension_ui_response", id, value }); } else { send({ type: "extension_ui_response", id, cancelled: true }); } }); break; } case "confirm": { const title = req.message ? `${req.title}: ${req.message}` : (req.title ?? "Confirm"); showSelectDialog(title, ["Yes", "No"], (value) => { send({ type: "extension_ui_response", id, confirmed: value === "Yes" }); }); break; } case "input": { const title = req.placeholder ? `${req.title} (${req.placeholder})` : (req.title ?? "Input"); showInputDialog(title, undefined, (value) => { if (value !== undefined) { send({ type: "extension_ui_response", id, value }); } else { send({ type: "extension_ui_response", id, cancelled: true }); } }); break; } case "editor": { const prefill = req.prefill?.replace(/\n/g, " "); showInputDialog(req.title ?? "Editor", prefill, (value) => { if (value !== undefined) { send({ type: "extension_ui_response", id, value }); } else { send({ type: "extension_ui_response", id, cancelled: true }); } }); break; } // Fire-and-forget methods: display as notification case "notify": { const notifyType = (req.notifyType as string) ?? "info"; const color = notifyType === "error" ? RED : notifyType === "warning" ? YELLOW : MAGENTA; outputLog.append(`${color}${BOLD}Notification:${RESET} ${req.message}`); tui.requestRender(); break; } case "setStatus": outputLog.append( `${MAGENTA}${BOLD}Notification:${RESET} ${DIM}[status: ${req.statusKey}]${RESET} ${req.statusText ?? "(cleared)"}`, ); tui.requestRender(); break; case "setWidget": { const lines = req.widgetLines; if (lines && lines.length > 0) { outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} ${DIM}[widget: ${req.widgetKey}]${RESET}`); for (const wl of lines) { outputLog.append(` ${DIM}${wl}${RESET}`); } tui.requestRender(); } break; } case "set_editor_text": promptInput.input.setValue((req.text as string) ?? ""); tui.requestRender(); break; } } // -- Slash commands (local, not sent to agent) -- function handleSlashCommand(cmd: string): boolean { switch (cmd) { case "/select": showSelectDialog("Pick a color", ["Red", "Green", "Blue", "Yellow"], (value) => { if (value) { outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} You picked: ${value}`); } else { outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} Selection cancelled`); } tui.requestRender(); }); return true; case "/confirm": showSelectDialog("Are you sure?", ["Yes", "No"], (value) => { const confirmed = value === "Yes"; outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} Confirmed: ${confirmed}`); tui.requestRender(); }); return true; case "/input": showInputDialog("Enter your name", undefined, (value) => { if (value) { outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} You entered: ${value}`); } else { outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} Input cancelled`); } tui.requestRender(); }); return true; case "/editor": showInputDialog("Edit text", "Hello, world!", (value) => { if (value) { outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} Submitted: ${value}`); } else { outputLog.append(`${MAGENTA}${BOLD}Notification:${RESET} Editor cancelled`); } tui.requestRender(); }); return true; default: return false; } } // -- Process agent stdout -- const decoder = new StringDecoder("utf8"); let stdoutBuffer = ""; const processLine = (line: string): void => { let data: Record; try { data = JSON.parse(line); } catch { return; } if (data.type === "response" && !data.success) { outputLog.append(`${RED}[error]${RESET} ${data.command}: ${data.error}`); tui.requestRender(); return; } if (data.type === "agent_start") { showLoading(); return; } if (data.type === "extension_ui_request") { handleExtensionUI(data as unknown as ExtensionUIRequest); return; } if (data.type === "message_update") { const evt = data.assistantMessageEvent as Record | undefined; if (evt?.type === "text_delta") { if (!hasTextOutput) { hasTextOutput = true; outputLog.append(""); outputLog.append(`${BLUE}${BOLD}Agent:${RESET}`); } const delta = evt.delta as string; const parts = delta.split("\n"); for (let i = 0; i < parts.length; i++) { if (i > 0) outputLog.append(""); if (parts[i]) outputLog.appendRaw(parts[i]); } tui.requestRender(); } return; } if (data.type === "tool_execution_start") { outputLog.append(`${DIM}[tool: ${data.toolName}]${RESET}`); tui.requestRender(); return; } if (data.type === "tool_execution_end") { const result = JSON.stringify(data.result).slice(0, 120); outputLog.append(`${DIM}[result: ${result}...]${RESET}`); tui.requestRender(); return; } if (data.type === "agent_settled") { isStreaming = false; hideLoading(); outputLog.append(""); tui.requestRender(); return; } }; // -- User input -- promptInput.input.onSubmit = (value) => { const trimmed = value.trim(); if (!trimmed) return; promptInput.input.setValue(""); if (handleSlashCommand(trimmed)) { outputLog.append(`${GREEN}${BOLD}You:${RESET} ${trimmed}`); tui.requestRender(); return; } outputLog.append(`${GREEN}${BOLD}You:${RESET} ${trimmed}`); send({ type: "prompt", message: trimmed }); tui.requestRender(); }; promptInput.onCtrlD = exit; promptInput.input.onEscape = () => { if (isStreaming) { send({ type: "abort" }); outputLog.append(`${YELLOW}[aborted]${RESET}`); tui.requestRender(); } else { exit(); } }; const processStdout = (chunk: Buffer): void => { stdoutBuffer += decoder.write(chunk); const lines = stdoutBuffer.split(/\r?\n/); stdoutBuffer = lines.pop() ?? ""; for (const line of lines) processLine(line); }; agent.stdout!.on("data", processStdout); agent.stdout!.on("end", () => { stdoutBuffer += decoder.end(); if (stdoutBuffer) processLine(stdoutBuffer); }); // -- Agent exit -- agent.on("exit", (code) => { tui.stop(); if (stderr) console.error(stderr); console.log(`Agent exited with code ${code}`); process.exit(code ?? 0); }); // -- Start -- outputLog.append(`${BOLD}RPC Chat${RESET}`); outputLog.append(`${DIM}Type a message and press Enter. Esc to abort or exit. Ctrl+D to quit.${RESET}`); outputLog.append(`${DIM}Slash commands: /select /confirm /input /editor${RESET}`); outputLog.append(""); tui.start(); } main().catch((err) => { console.error(err); process.exit(1); });