docs(coding-agent): document extension UI protocol in RPC docs, add examples (#1144)

This commit is contained in:
Aliou Diallo 2026-02-01 13:25:18 +01:00 committed by GitHub
parent 4ca7bbe450
commit 7feae0d5c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 951 additions and 1 deletions

View file

@ -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

View file

@ -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");

View file

@ -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) |

View file

@ -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");
},
});
}

View file

@ -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<string, unknown>): 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<string, unknown>;
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<string, unknown> | 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);
});