mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 13:03:42 +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
|
|
@ -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) |
|
||||
|
|
|
|||
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