mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 09:01:20 +00:00
docs(coding-agent): document extension UI protocol in RPC docs, add examples (#1144)
This commit is contained in:
parent
4ca7bbe450
commit
7feae0d5c2
5 changed files with 951 additions and 1 deletions
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.50.9] - 2026-02-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Error Handling
|
||||||
|
|
||||||
Failed commands return a response with `success: false`:
|
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/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
|
||||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
|
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
|
||||||
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
|
- [`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
|
### 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.
|
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
|
```javascript
|
||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
const readline = require("readline");
|
const readline = require("readline");
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
| `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 |
|
| `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 |
|
| `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()` |
|
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
|
||||||
| `rainbow-editor.ts` | Animated rainbow text effect via custom editor |
|
| `rainbow-editor.ts` | Animated rainbow text effect via custom editor |
|
||||||
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
|
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
|
||||||
|
|
|
||||||
124
packages/coding-agent/examples/extensions/rpc-demo.ts
Normal file
124
packages/coding-agent/examples/extensions/rpc-demo.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
632
packages/coding-agent/examples/rpc-extension-ui.ts
Normal file
632
packages/coding-agent/examples/rpc-extension-ui.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue