From 7feae0d5c24bfd6c1d201185d30e19f819e6eef2 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Sun, 1 Feb 2026 13:25:18 +0100 Subject: [PATCH] docs(coding-agent): document extension UI protocol in RPC docs, add examples (#1144) --- packages/coding-agent/CHANGELOG.md | 6 + packages/coding-agent/docs/rpc.md | 189 +++++- .../examples/extensions/README.md | 1 + .../examples/extensions/rpc-demo.ts | 124 ++++ .../coding-agent/examples/rpc-extension-ui.ts | 632 ++++++++++++++++++ 5 files changed, 951 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/examples/extensions/rpc-demo.ts create mode 100644 packages/coding-agent/examples/rpc-extension-ui.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 03543a8f..b720f4b4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- Added Extension UI Protocol documentation to RPC docs covering all request/response types for extension dialogs and notifications ([#1144](https://github.com/badlogic/pi-mono/pull/1144) by [@aliou](https://github.com/aliou)) +- Added `rpc-demo.ts` example extension exercising all RPC-supported extension UI methods ([#1144](https://github.com/badlogic/pi-mono/pull/1144) by [@aliou](https://github.com/aliou)) +- Added `rpc-extension-ui.ts` TUI example client demonstrating the extension UI protocol with interactive dialogs ([#1144](https://github.com/badlogic/pi-mono/pull/1144) by [@aliou](https://github.com/aliou)) + ## [0.50.9] - 2026-02-01 ### Added diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index ee4b9f61..483ab6dd 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -903,6 +903,191 @@ Emitted when an extension throws an error. } ``` +## Extension UI Protocol + +Extensions can request user interaction via `ctx.ui.select()`, `ctx.ui.confirm()`, etc. In RPC mode, these are translated into a request/response sub-protocol on top of the base command/event flow. + +There are two categories of extension UI methods: + +- **Dialog methods** (`select`, `confirm`, `input`, `editor`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`. +- **Fire-and-forget methods** (`notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`): emit an `extension_ui_request` on stdout but do not expect a response. The client can display the information or ignore it. + +If a dialog method includes a `timeout` field, the agent-side will auto-resolve with a default value when the timeout expires. The client does not need to track timeouts. + +Some `ExtensionUIContext` methods are not supported in RPC mode because they require direct TUI access: +- `custom()` returns `undefined` +- `setWorkingMessage()`, `setFooter()`, `setHeader()`, `setEditorComponent()` are no-ops +- `getEditorText()` returns `""` + +### Extension UI Requests (stdout) + +All requests have `type: "extension_ui_request"`, a unique `id`, and a `method` field. + +#### select + +Prompt the user to choose from a list. Dialog methods with a `timeout` field include the timeout in milliseconds; the agent auto-resolves with `undefined` if the client doesn't respond in time. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-1", + "method": "select", + "title": "Allow dangerous command?", + "options": ["Allow", "Block"], + "timeout": 10000 +} +``` + +Expected response: `extension_ui_response` with `value` (the selected option string) or `cancelled: true`. + +#### confirm + +Prompt the user for yes/no confirmation. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-2", + "method": "confirm", + "title": "Clear session?", + "message": "All messages will be lost.", + "timeout": 5000 +} +``` + +Expected response: `extension_ui_response` with `confirmed: true/false` or `cancelled: true`. + +#### input + +Prompt the user for free-form text. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-3", + "method": "input", + "title": "Enter a value", + "placeholder": "type something..." +} +``` + +Expected response: `extension_ui_response` with `value` (the entered text) or `cancelled: true`. + +#### editor + +Open a multi-line text editor with optional prefilled content. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-4", + "method": "editor", + "title": "Edit some text", + "prefill": "Line 1\nLine 2\nLine 3" +} +``` + +Expected response: `extension_ui_response` with `value` (the edited text) or `cancelled: true`. + +#### notify + +Display a notification. Fire-and-forget, no response expected. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-5", + "method": "notify", + "message": "Command blocked by user", + "notifyType": "warning" +} +``` + +The `notifyType` field is `"info"`, `"warning"`, or `"error"`. Defaults to `"info"` if omitted. + +#### setStatus + +Set or clear a status entry in the footer/status bar. Fire-and-forget. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-6", + "method": "setStatus", + "statusKey": "my-ext", + "statusText": "Turn 3 running..." +} +``` + +Send `statusText: undefined` (or omit it) to clear the status entry for that key. + +#### setWidget + +Set or clear a widget (block of text lines) displayed above or below the editor. Fire-and-forget. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-7", + "method": "setWidget", + "widgetKey": "my-ext", + "widgetLines": ["--- My Widget ---", "Line 1", "Line 2"], + "widgetPlacement": "aboveEditor" +} +``` + +Send `widgetLines: undefined` (or omit it) to clear the widget. The `widgetPlacement` field is `"aboveEditor"` (default) or `"belowEditor"`. Only string arrays are supported in RPC mode; component factories are ignored. + +#### setTitle + +Set the terminal window/tab title. Fire-and-forget. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-8", + "method": "setTitle", + "title": "pi - my project" +} +``` + +#### set_editor_text + +Set the text in the input editor. Fire-and-forget. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-9", + "method": "set_editor_text", + "text": "prefilled text for the user" +} +``` + +### Extension UI Responses (stdin) + +Responses are sent for dialog methods only (`select`, `confirm`, `input`, `editor`). The `id` must match the request. + +#### Value response (select, input, editor) + +```json +{"type": "extension_ui_response", "id": "uuid-1", "value": "Allow"} +``` + +#### Confirmation response (confirm) + +```json +{"type": "extension_ui_response", "id": "uuid-2", "confirmed": true} +``` + +#### Cancellation response (any dialog) + +Dismiss any dialog method. The extension receives `undefined` (for select/input/editor) or `false` (for confirm). + +```json +{"type": "extension_ui_response", "id": "uuid-3", "cancelled": true} +``` + ## Error Handling Failed commands return a response with `success: false`: @@ -933,7 +1118,7 @@ Source files: - [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage` - [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent` - [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage` -- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types +- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types, extension UI request/response types ### Model @@ -1082,6 +1267,8 @@ for event in read_events(): See [`test/rpc-example.ts`](../test/rpc-example.ts) for a complete interactive example, or [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts) for a typed client implementation. +For a complete example of handling the extension UI protocol, see [`examples/rpc-extension-ui.ts`](../examples/rpc-extension-ui.ts) which pairs with the [`examples/extensions/rpc-demo.ts`](../examples/extensions/rpc-demo.ts) extension. + ```javascript const { spawn } = require("child_process"); const readline = require("readline"); diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index c1011c39..5e5da8f5 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -53,6 +53,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | | `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions | | `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs | +| `rpc-demo.ts` | Exercises all RPC-supported extension UI methods; pair with [`examples/rpc-extension-ui.ts`](../rpc-extension-ui.ts) | | `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` | | `rainbow-editor.ts` | Animated rainbow text effect via custom editor | | `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) | diff --git a/packages/coding-agent/examples/extensions/rpc-demo.ts b/packages/coding-agent/examples/extensions/rpc-demo.ts new file mode 100644 index 00000000..4f7e3f98 --- /dev/null +++ b/packages/coding-agent/examples/extensions/rpc-demo.ts @@ -0,0 +1,124 @@ +/** + * RPC Extension UI Demo + * + * Purpose-built extension that exercises all RPC-supported extension UI methods. + * Designed to be loaded alongside the rpc-extension-ui-example.ts script to + * demonstrate the full extension UI protocol. + * + * UI methods exercised: + * - select() - on tool_call for dangerous bash commands + * - confirm() - on session_before_switch + * - input() - via /rpc-input command + * - editor() - via /rpc-editor command + * - notify() - after each dialog completes + * - setStatus() - on turn_start/turn_end + * - setWidget() - on session_start + * - setTitle() - on session_start and session_switch + * - setEditorText() - via /rpc-prefill command + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + let turnCount = 0; + + // -- setTitle, setWidget, setStatus on session lifecycle -- + + pi.on("session_start", async (_event, ctx) => { + ctx.ui.setTitle("pi RPC Demo"); + ctx.ui.setWidget("rpc-demo", ["--- RPC Extension UI Demo ---", "Loaded and ready."]); + ctx.ui.setStatus("rpc-demo", `Turns: ${turnCount}`); + }); + + pi.on("session_switch", async (_event, ctx) => { + turnCount = 0; + ctx.ui.setTitle("pi RPC Demo (new session)"); + ctx.ui.setStatus("rpc-demo", `Turns: ${turnCount}`); + }); + + // -- setStatus on turn lifecycle -- + + pi.on("turn_start", async (_event, ctx) => { + turnCount++; + ctx.ui.setStatus("rpc-demo", `Turn ${turnCount} running...`); + }); + + pi.on("turn_end", async (_event, ctx) => { + ctx.ui.setStatus("rpc-demo", `Turn ${turnCount} done`); + }); + + // -- select on dangerous tool calls -- + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return undefined; + + const command = event.input.command as string; + const isDangerous = /\brm\s+(-rf?|--recursive)/i.test(command) || /\bsudo\b/i.test(command); + + if (isDangerous) { + if (!ctx.hasUI) { + return { block: true, reason: "Dangerous command blocked (no UI)" }; + } + + const choice = await ctx.ui.select(`Dangerous command: ${command}`, ["Allow", "Block"]); + if (choice !== "Allow") { + ctx.ui.notify("Command blocked by user", "warning"); + return { block: true, reason: "Blocked by user" }; + } + ctx.ui.notify("Command allowed", "info"); + } + + return undefined; + }); + + // -- confirm on session clear -- + + pi.on("session_before_switch", async (event, ctx) => { + if (event.reason !== "new") return; + if (!ctx.hasUI) return; + + const confirmed = await ctx.ui.confirm("Clear session?", "All messages will be lost."); + if (!confirmed) { + ctx.ui.notify("Clear cancelled", "info"); + return { cancel: true }; + } + }); + + // -- input via command -- + + pi.registerCommand("rpc-input", { + description: "Prompt for text input (demonstrates ctx.ui.input in RPC)", + handler: async (_args, ctx) => { + const value = await ctx.ui.input("Enter a value", "type something..."); + if (value) { + ctx.ui.notify(`You entered: ${value}`, "info"); + } else { + ctx.ui.notify("Input cancelled", "info"); + } + }, + }); + + // -- editor via command -- + + pi.registerCommand("rpc-editor", { + description: "Open multi-line editor (demonstrates ctx.ui.editor in RPC)", + handler: async (_args, ctx) => { + const text = await ctx.ui.editor("Edit some text", "Line 1\nLine 2\nLine 3"); + if (text) { + ctx.ui.notify(`Editor submitted (${text.split("\n").length} lines)`, "info"); + } else { + ctx.ui.notify("Editor cancelled", "info"); + } + }, + }); + + // -- setEditorText via command -- + + pi.registerCommand("rpc-prefill", { + description: "Prefill the input editor (demonstrates ctx.ui.setEditorText in RPC)", + handler: async (_args, ctx) => { + ctx.ui.setEditorText("This text was set by the rpc-demo extension."); + ctx.ui.notify("Editor prefilled", "info"); + }, + }); +} diff --git a/packages/coding-agent/examples/rpc-extension-ui.ts b/packages/coding-agent/examples/rpc-extension-ui.ts new file mode 100644 index 00000000..93b982b5 --- /dev/null +++ b/packages/coding-agent/examples/rpc-extension-ui.ts @@ -0,0 +1,632 @@ +/** + * 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: npx tsx 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 * as readline from "node:readline"; +import { fileURLToPath } from "node:url"; +import { type Component, Container, Input, matchesKey, ProcessTerminal, SelectList, TUI } from "@mariozechner/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 +// ============================================================================ + +class OutputLog implements Component { + private lines: string[] = []; + private maxLines = 1000; + private visibleLines = 0; + + setVisibleLines(n: number): void { + this.visibleLines = n; + } + + append(line: string): void { + this.lines.push(line); + if (this.lines.length > this.maxLines) { + this.lines = this.lines.slice(-this.maxLines); + } + } + + appendRaw(text: string): void { + if (this.lines.length === 0) { + this.lines.push(text); + } else { + this.lines[this.lines.length - 1] += text; + } + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.lines.length === 0) return [""]; + const n = this.visibleLines > 0 ? this.visibleLines : this.lines.length; + return this.lines.slice(-n).map((l) => l.slice(0, width)); + } +} + +// ============================================================================ +// Loading indicator: "Agent: Working." -> ".." -> "..." -> "." +// ============================================================================ + +class LoadingIndicator implements Component { + private dots = 1; + private intervalId: NodeJS.Timeout | null = null; + private tui: TUI | null = null; + + start(tui: TUI): void { + this.tui = tui; + this.dots = 1; + this.intervalId = setInterval(() => { + this.dots = (this.dots % 3) + 1; + this.tui?.requestRender(); + }, 400); + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + invalidate(): void {} + + render(_width: number): string[] { + return [`${BLUE}${BOLD}Agent:${RESET} ${DIM}Working${".".repeat(this.dots)}${RESET}`]; + } +} + +// ============================================================================ +// Prompt input: label + single-line input +// ============================================================================ + +class PromptInput implements Component { + readonly input: Input; + onCtrlD?: () => void; + + constructor() { + this.input = new Input(); + } + + handleInput(data: string): void { + if (matchesKey(data, "ctrl+d")) { + this.onCtrlD?.(); + return; + } + this.input.handleInput(data); + } + + invalidate(): void { + this.input.invalidate(); + } + + render(width: number): string[] { + return [`${GREEN}${BOLD}You:${RESET}`, ...this.input.render(width)]; + } +} + +// ============================================================================ +// Dialog components: replace the prompt input during interactive requests +// ============================================================================ + +class SelectDialog implements Component { + private list: SelectList; + private title: string; + onSelect?: (value: string) => void; + onCancel?: () => void; + + constructor(title: string, options: string[]) { + this.title = title; + const items = options.map((o) => ({ value: o, label: o })); + this.list = new SelectList(items, Math.min(items.length, 8), { + selectedPrefix: (t) => `${MAGENTA}${t}${RESET}`, + selectedText: (t) => `${MAGENTA}${t}${RESET}`, + description: (t) => `${DIM}${t}${RESET}`, + scrollInfo: (t) => `${DIM}${t}${RESET}`, + noMatch: (t) => `${YELLOW}${t}${RESET}`, + }); + this.list.onSelect = (item) => this.onSelect?.(item.value); + this.list.onCancel = () => this.onCancel?.(); + } + + handleInput(data: string): void { + this.list.handleInput(data); + } + + invalidate(): void { + this.list.invalidate(); + } + + render(width: number): string[] { + return [ + `${MAGENTA}${BOLD}${this.title}${RESET}`, + ...this.list.render(width), + `${DIM}Up/Down, Enter to select, Esc to cancel${RESET}`, + ]; + } +} + +class InputDialog implements Component { + private dialogInput: Input; + private title: string; + onCtrlD?: () => void; + + constructor(title: string, prefill?: string) { + this.title = title; + this.dialogInput = new Input(); + if (prefill) this.dialogInput.setValue(prefill); + } + + set onSubmit(fn: ((value: string) => void) | undefined) { + this.dialogInput.onSubmit = fn; + } + + set onEscape(fn: (() => void) | undefined) { + this.dialogInput.onEscape = fn; + } + + get inputComponent(): Input { + return this.dialogInput; + } + + handleInput(data: string): void { + if (matchesKey(data, "ctrl+d")) { + this.onCtrlD?.(); + return; + } + this.dialogInput.handleInput(data); + } + + invalidate(): void { + this.dialogInput.invalidate(); + } + + render(width: number): string[] { + return [ + `${MAGENTA}${BOLD}${this.title}${RESET}`, + ...this.dialogInput.render(width), + `${DIM}Enter to submit, Esc to cancel${RESET}`, + ]; + } +} + +// ============================================================================ +// 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-extension", "--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 stdoutRl = readline.createInterface({ input: agent.stdout!, terminal: false }); + + stdoutRl.on("line", (line) => { + 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_end") { + 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(); + } + }; + + // -- 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); +});