feat: add gateway TUI
This commit is contained in:
54
docs/tui.md
Normal file
54
docs/tui.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
summary: "Terminal UI (TUI) for Clawdis via the Gateway"
|
||||
read_when:
|
||||
- You want a terminal UI that connects to the Gateway from any machine
|
||||
- You are debugging the TUI client or Gateway chat stream
|
||||
---
|
||||
# TUI (Gateway chat client)
|
||||
|
||||
Updated: 2026-01-03
|
||||
|
||||
## What it is
|
||||
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
|
||||
- Works locally (loopback) or remotely (Tailscale/SSH tunnel) without running a separate agent process.
|
||||
|
||||
## Run
|
||||
```bash
|
||||
clawdis tui
|
||||
```
|
||||
|
||||
### Remote
|
||||
```bash
|
||||
clawdis tui --url ws://127.0.0.1:18789 --token <gateway-token>
|
||||
```
|
||||
Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
|
||||
## Options
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
|
||||
- `--token <token>`: Gateway token (if required).
|
||||
- `--password <password>`: Gateway password (if required).
|
||||
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
|
||||
- `--deliver`: Deliver assistant replies to the provider.
|
||||
- `--thinking <level>`: Override thinking level for sends.
|
||||
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
||||
- `--history-limit <n>`: History entries to load (default 200).
|
||||
|
||||
## Controls
|
||||
- Enter: send message
|
||||
- Esc: abort active run
|
||||
- Ctrl+C: exit
|
||||
|
||||
## Slash commands
|
||||
- `/help`
|
||||
- `/session <key>`
|
||||
- `/abort`
|
||||
- `/exit`
|
||||
|
||||
## Notes
|
||||
- The TUI shows Gateway chat deltas (`event: chat`) and final responses.
|
||||
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
|
||||
|
||||
## Files
|
||||
- CLI: `src/cli/tui-cli.ts`
|
||||
- Runner: `src/tui/tui.ts`
|
||||
- Gateway client: `src/tui/gateway-chat.ts`
|
||||
@@ -75,6 +75,7 @@
|
||||
"@mariozechner/pi-agent-core": "^0.31.1",
|
||||
"@mariozechner/pi-ai": "^0.31.1",
|
||||
"@mariozechner/pi-coding-agent": "^0.31.1",
|
||||
"@mariozechner/pi-tui": "^0.31.1",
|
||||
"@sinclair/typebox": "0.34.46",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"ajv": "^8.17.1",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -37,6 +37,9 @@ importers:
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.46
|
||||
version: 0.34.46
|
||||
|
||||
@@ -24,6 +24,7 @@ import { registerGatewayCli } from "./gateway-cli.js";
|
||||
import { registerHooksCli } from "./hooks-cli.js";
|
||||
import { registerNodesCli } from "./nodes-cli.js";
|
||||
import { forceFreePort } from "./ports.js";
|
||||
import { registerTuiCli } from "./tui-cli.js";
|
||||
|
||||
export { forceFreePort };
|
||||
|
||||
@@ -394,6 +395,7 @@ Examples:
|
||||
registerCanvasCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerNodesCli(program);
|
||||
registerTuiCli(program);
|
||||
registerCronCli(program);
|
||||
registerDnsCli(program);
|
||||
registerHooksCli(program);
|
||||
|
||||
42
src/cli/tui-cli.ts
Normal file
42
src/cli/tui-cli.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { runTui } from "../tui/tui.js";
|
||||
|
||||
export function registerTuiCli(program: Command) {
|
||||
program
|
||||
.command("tui")
|
||||
.description("Open a terminal UI connected to the Gateway")
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (if required)")
|
||||
.option(
|
||||
"--session <key>",
|
||||
"Session key (default: session.mainKey from config)",
|
||||
)
|
||||
.option("--deliver", "Deliver assistant replies", false)
|
||||
.option("--thinking <level>", "Thinking level override")
|
||||
.option("--timeout-ms <ms>", "Agent timeout in ms", "30000")
|
||||
.option("--history-limit <n>", "History entries to load", "200")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const timeoutMs = Number.parseInt(String(opts.timeoutMs ?? "30000"), 10);
|
||||
const historyLimit = Number.parseInt(String(opts.historyLimit ?? "200"), 10);
|
||||
await runTui({
|
||||
url: opts.url as string | undefined,
|
||||
token: opts.token as string | undefined,
|
||||
password: opts.password as string | undefined,
|
||||
session: opts.session as string | undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
thinking: opts.thinking as string | undefined,
|
||||
timeoutMs: Number.isNaN(timeoutMs) ? undefined : timeoutMs,
|
||||
historyLimit: Number.isNaN(historyLimit) ? undefined : historyLimit,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
161
src/tui/gateway-chat.ts
Normal file
161
src/tui/gateway-chat.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export type GatewayConnectionOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type ChatSendOptions = {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
thinking?: string;
|
||||
deliver?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayEvent = {
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export class GatewayChatClient {
|
||||
private client: GatewayClient;
|
||||
private readyPromise: Promise<void>;
|
||||
private resolveReady?: () => void;
|
||||
readonly connection: { url: string; token?: string; password?: string };
|
||||
|
||||
onEvent?: (evt: GatewayEvent) => void;
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: (reason: string) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
|
||||
constructor(opts: GatewayConnectionOptions) {
|
||||
const resolved = resolveGatewayConnection(opts);
|
||||
this.connection = resolved;
|
||||
|
||||
this.readyPromise = new Promise((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
||||
this.client = new GatewayClient({
|
||||
url: resolved.url,
|
||||
token: resolved.token,
|
||||
password: resolved.password,
|
||||
clientName: "clawdis-tui",
|
||||
clientVersion: VERSION,
|
||||
platform: process.platform,
|
||||
mode: "tui",
|
||||
instanceId: randomUUID(),
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
onHelloOk: () => {
|
||||
this.resolveReady?.();
|
||||
this.onConnected?.();
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
this.onEvent?.({ event: evt.event, payload: evt.payload });
|
||||
},
|
||||
onClose: (_code, reason) => {
|
||||
this.onDisconnected?.(reason);
|
||||
},
|
||||
onGap: (info) => {
|
||||
this.onGap?.(info);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.client.start();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.client.stop();
|
||||
}
|
||||
|
||||
async waitForReady() {
|
||||
await this.readyPromise;
|
||||
}
|
||||
|
||||
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }>
|
||||
{
|
||||
const runId = randomUUID();
|
||||
await this.client.request("chat.send", {
|
||||
sessionKey: opts.sessionKey,
|
||||
message: opts.message,
|
||||
thinking: opts.thinking,
|
||||
deliver: opts.deliver,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
return { runId };
|
||||
}
|
||||
|
||||
async abortChat(opts: { sessionKey: string; runId: string }) {
|
||||
return await this.client.request<{ ok: boolean; aborted: boolean }>(
|
||||
"chat.abort",
|
||||
{
|
||||
sessionKey: opts.sessionKey,
|
||||
runId: opts.runId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async loadHistory(opts: { sessionKey: string; limit?: number }) {
|
||||
return await this.client.request("chat.history", {
|
||||
sessionKey: opts.sessionKey,
|
||||
limit: opts.limit,
|
||||
});
|
||||
}
|
||||
|
||||
async listSessions(opts?: { limit?: number; activeMinutes?: number }) {
|
||||
return await this.client.request("sessions.list", {
|
||||
limit: opts?.limit,
|
||||
activeMinutes: opts?.activeMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
|
||||
const config = loadConfig();
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
||||
const authToken = config.gateway?.auth?.token;
|
||||
|
||||
const url =
|
||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
: undefined) ||
|
||||
(typeof remote?.url === "string" && remote.url.trim().length > 0
|
||||
? remote.url.trim()
|
||||
: undefined) ||
|
||||
"ws://127.0.0.1:18789";
|
||||
|
||||
const token =
|
||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||
? opts.token.trim()
|
||||
: undefined) ||
|
||||
(isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0
|
||||
? authToken.trim()
|
||||
: undefined));
|
||||
|
||||
const password =
|
||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||
? opts.password.trim()
|
||||
: undefined) ||
|
||||
process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() ||
|
||||
(typeof remote?.password === "string" && remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined);
|
||||
|
||||
return { url, token, password };
|
||||
}
|
||||
40
src/tui/layout.ts
Normal file
40
src/tui/layout.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
|
||||
export class ChatLayout implements Component {
|
||||
constructor(
|
||||
private header: Component,
|
||||
private messages: Component,
|
||||
private status: Component,
|
||||
private input: Component,
|
||||
) {}
|
||||
|
||||
invalidate(): void {
|
||||
this.header.invalidate?.();
|
||||
this.messages.invalidate?.();
|
||||
this.status.invalidate?.();
|
||||
this.input.invalidate?.();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const rows = process.stdout.rows ?? 24;
|
||||
const headerLines = this.header.render(width);
|
||||
const statusLines = this.status.render(width);
|
||||
const inputLines = this.input.render(width);
|
||||
|
||||
const reserved = headerLines.length + statusLines.length + inputLines.length;
|
||||
const available = Math.max(rows - reserved, 0);
|
||||
|
||||
const messageLines = this.messages.render(width);
|
||||
const slicedMessages =
|
||||
available > 0
|
||||
? messageLines.slice(Math.max(0, messageLines.length - available))
|
||||
: [];
|
||||
|
||||
const lines = [...headerLines, ...slicedMessages, ...statusLines, ...inputLines];
|
||||
if (lines.length < rows) {
|
||||
const padding = Array.from({ length: rows - lines.length }, () => "");
|
||||
return [...lines, ...padding];
|
||||
}
|
||||
return lines.slice(0, rows);
|
||||
}
|
||||
}
|
||||
81
src/tui/message-list.ts
Normal file
81
src/tui/message-list.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import crypto from "node:crypto";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { MarkdownTheme, DefaultTextStyle } from "@mariozechner/pi-tui";
|
||||
import { theme } from "./theme.js";
|
||||
|
||||
export class MessageList extends Container {
|
||||
private assistantById = new Map<string, Markdown>();
|
||||
|
||||
constructor(
|
||||
private markdownTheme: MarkdownTheme,
|
||||
private styles: {
|
||||
user: DefaultTextStyle;
|
||||
assistant: DefaultTextStyle;
|
||||
system: DefaultTextStyle;
|
||||
tool: DefaultTextStyle;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.assistantById.clear();
|
||||
this.clear();
|
||||
}
|
||||
|
||||
addSystem(text: string): void {
|
||||
this.addMessage("system", text, this.styles.system);
|
||||
}
|
||||
|
||||
addTool(text: string): void {
|
||||
this.addMessage("tool", text, this.styles.tool);
|
||||
}
|
||||
|
||||
addUser(text: string): void {
|
||||
this.addMessage("user", text, this.styles.user);
|
||||
}
|
||||
|
||||
addAssistant(text: string, id?: string): string {
|
||||
const messageId = id ?? crypto.randomUUID();
|
||||
const label = new Text(theme.assistant("clawd"), 1, 0);
|
||||
const body = new Markdown(text, 1, 0, this.markdownTheme, this.styles.assistant);
|
||||
const group = new Container();
|
||||
group.addChild(label);
|
||||
group.addChild(body);
|
||||
this.addChild(group);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.assistantById.set(messageId, body);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
updateAssistant(id: string, text: string): void {
|
||||
const component = this.assistantById.get(id);
|
||||
if (!component) return;
|
||||
component.setText(text);
|
||||
}
|
||||
|
||||
private addMessage(
|
||||
role: MessageEntry["role"],
|
||||
text: string,
|
||||
style: DefaultTextStyle,
|
||||
) {
|
||||
const messageId = crypto.randomUUID();
|
||||
const label = new Text(
|
||||
role === "user"
|
||||
? theme.user("you")
|
||||
: role === "system"
|
||||
? theme.system("system")
|
||||
: theme.dim("tool"),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
const body = new Markdown(text, 1, 0, this.markdownTheme, style);
|
||||
const group = new Container();
|
||||
group.addChild(label);
|
||||
group.addChild(body);
|
||||
this.addChild(group);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
}
|
||||
}
|
||||
28
src/tui/theme.ts
Normal file
28
src/tui/theme.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import chalk from "chalk";
|
||||
import type { MarkdownTheme } from "@mariozechner/pi-tui";
|
||||
|
||||
export const markdownTheme: MarkdownTheme = {
|
||||
heading: (text) => chalk.bold.cyan(text),
|
||||
link: (text) => chalk.blue(text),
|
||||
linkUrl: (text) => chalk.gray(text),
|
||||
code: (text) => chalk.yellow(text),
|
||||
codeBlock: (text) => chalk.yellow(text),
|
||||
codeBlockBorder: (text) => chalk.gray(text),
|
||||
quote: (text) => chalk.gray(text),
|
||||
quoteBorder: (text) => chalk.gray(text),
|
||||
hr: (text) => chalk.gray(text),
|
||||
listBullet: (text) => chalk.cyan(text),
|
||||
bold: (text) => chalk.bold(text),
|
||||
italic: (text) => chalk.italic(text),
|
||||
strikethrough: (text) => chalk.strikethrough(text),
|
||||
underline: (text) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const theme = {
|
||||
header: (text: string) => chalk.bold.cyan(text),
|
||||
dim: (text: string) => chalk.gray(text),
|
||||
user: (text: string) => chalk.cyan(text),
|
||||
assistant: (text: string) => chalk.green(text),
|
||||
system: (text: string) => chalk.magenta(text),
|
||||
error: (text: string) => chalk.red(text),
|
||||
};
|
||||
331
src/tui/tui.ts
Normal file
331
src/tui/tui.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
type Component,
|
||||
Input,
|
||||
ProcessTerminal,
|
||||
Text,
|
||||
TUI,
|
||||
isCtrlC,
|
||||
isEscape,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { ChatLayout } from "./layout.js";
|
||||
import { MessageList } from "./message-list.js";
|
||||
import { markdownTheme, theme } from "./theme.js";
|
||||
|
||||
export type TuiOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
session?: string;
|
||||
deliver?: boolean;
|
||||
thinking?: string;
|
||||
timeoutMs?: number;
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
type ChatEvent = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
message?: unknown;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
class InputWrapper implements Component {
|
||||
constructor(
|
||||
private input: Input,
|
||||
private onAbort: () => void,
|
||||
private onExit: () => void,
|
||||
) {
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isCtrlC(data)) {
|
||||
this.onExit();
|
||||
return;
|
||||
}
|
||||
if (isEscape(data)) {
|
||||
this.onAbort();
|
||||
return;
|
||||
}
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.input.render(width);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(message?: unknown): string {
|
||||
if (!message || typeof message !== "object") return "";
|
||||
const record = message as Record<string, unknown>;
|
||||
const content = Array.isArray(record.content) ? record.content : [];
|
||||
const parts = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") return "";
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === "text" && typeof b.text === "string") return b.text;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function renderHistoryEntry(entry: unknown): { role: "user" | "assistant"; text: string } | null {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const role = record.role === "user" || record.role === "assistant" ? record.role : null;
|
||||
if (!role) return null;
|
||||
const text = extractText(record);
|
||||
if (!text) return null;
|
||||
return { role, text };
|
||||
}
|
||||
|
||||
export async function runTui(opts: TuiOptions) {
|
||||
const config = loadConfig();
|
||||
const defaultSession =
|
||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||
let currentSession = defaultSession;
|
||||
let activeRunId: string | null = null;
|
||||
let streamingMessageId: string | null = null;
|
||||
let historyLoaded = false;
|
||||
|
||||
const messages = new MessageList(markdownTheme, {
|
||||
user: { color: theme.user },
|
||||
assistant: { color: theme.assistant },
|
||||
system: { color: theme.system, italic: true },
|
||||
tool: { color: theme.dim, italic: true },
|
||||
});
|
||||
|
||||
const header = new Text("", 1, 0);
|
||||
const status = new Text("", 1, 0);
|
||||
const input = new Input();
|
||||
|
||||
const tui = new TUI(new ProcessTerminal());
|
||||
const inputWrapper = new InputWrapper(
|
||||
input,
|
||||
async () => {
|
||||
if (!activeRunId) return;
|
||||
try {
|
||||
await client.abortChat({ sessionKey: currentSession, runId: activeRunId });
|
||||
} catch (err) {
|
||||
messages.addSystem(`Abort failed: ${String(err)}`);
|
||||
}
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("aborted");
|
||||
tui.requestRender();
|
||||
},
|
||||
() => {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
},
|
||||
);
|
||||
|
||||
const layout = new ChatLayout(header, messages, status, inputWrapper);
|
||||
tui.addChild(layout);
|
||||
tui.setFocus(inputWrapper);
|
||||
|
||||
const client = new GatewayChatClient({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
});
|
||||
|
||||
const updateHeader = () => {
|
||||
header.setText(
|
||||
theme.header(
|
||||
`clawdis tui - ${client.connection.url} - session ${currentSession}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const setStatus = (text: string) => {
|
||||
status.setText(theme.dim(text));
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const history = await client.loadHistory({
|
||||
sessionKey: currentSession,
|
||||
limit: opts.historyLimit ?? 200,
|
||||
});
|
||||
const historyRecord = history as { messages?: unknown[] } | undefined;
|
||||
messages.clearAll();
|
||||
messages.addSystem(`session ${currentSession}`);
|
||||
for (const entry of historyRecord?.messages ?? []) {
|
||||
const parsed = renderHistoryEntry(entry);
|
||||
if (!parsed) continue;
|
||||
if (parsed.role === "user") messages.addUser(parsed.text);
|
||||
if (parsed.role === "assistant") messages.addAssistant(parsed.text);
|
||||
}
|
||||
historyLoaded = true;
|
||||
tui.requestRender();
|
||||
} catch (err) {
|
||||
messages.addSystem(`history failed: ${String(err)}`);
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as ChatEvent;
|
||||
if (evt.sessionKey !== currentSession) return;
|
||||
|
||||
if (evt.state === "delta") {
|
||||
const text = extractText(evt.message);
|
||||
if (!text) return;
|
||||
if (!streamingMessageId || activeRunId !== evt.runId) {
|
||||
streamingMessageId = messages.addAssistant(text, evt.runId);
|
||||
activeRunId = evt.runId;
|
||||
} else {
|
||||
messages.updateAssistant(streamingMessageId, text);
|
||||
}
|
||||
setStatus("streaming");
|
||||
}
|
||||
|
||||
if (evt.state === "final") {
|
||||
const text = extractText(evt.message);
|
||||
if (streamingMessageId && activeRunId === evt.runId) {
|
||||
messages.updateAssistant(streamingMessageId, text || "(no output)");
|
||||
} else if (text) {
|
||||
messages.addAssistant(text, evt.runId);
|
||||
}
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
if (evt.state === "aborted") {
|
||||
messages.addSystem("run aborted");
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("aborted");
|
||||
}
|
||||
|
||||
if (evt.state === "error") {
|
||||
messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("error");
|
||||
}
|
||||
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const handleCommand = async (raw: string) => {
|
||||
const [command, ...rest] = raw.slice(1).trim().split(/\s+/);
|
||||
const arg = rest.join(" ").trim();
|
||||
switch (command) {
|
||||
case "help": {
|
||||
messages.addSystem("/help /session <key> /abort /exit");
|
||||
break;
|
||||
}
|
||||
case "session": {
|
||||
if (!arg) {
|
||||
messages.addSystem("missing session key");
|
||||
break;
|
||||
}
|
||||
currentSession = arg;
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
historyLoaded = false;
|
||||
updateHeader();
|
||||
await loadHistory();
|
||||
break;
|
||||
}
|
||||
case "abort": {
|
||||
if (!activeRunId) {
|
||||
messages.addSystem("no active run");
|
||||
break;
|
||||
}
|
||||
await client.abortChat({ sessionKey: currentSession, runId: activeRunId });
|
||||
break;
|
||||
}
|
||||
case "exit":
|
||||
case "quit": {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
default:
|
||||
messages.addSystem(`unknown command: /${command}`);
|
||||
break;
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
try {
|
||||
messages.addUser(text);
|
||||
tui.requestRender();
|
||||
setStatus("sending");
|
||||
const { runId } = await client.sendChat({
|
||||
sessionKey: currentSession,
|
||||
message: text,
|
||||
thinking: opts.thinking,
|
||||
deliver: opts.deliver,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
activeRunId = runId;
|
||||
streamingMessageId = null;
|
||||
setStatus("waiting");
|
||||
} catch (err) {
|
||||
messages.addSystem(`send failed: ${String(err)}`);
|
||||
setStatus("error");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
input.onSubmit = (value) => {
|
||||
const text = value.trim();
|
||||
input.setValue("");
|
||||
if (!text) return;
|
||||
if (text.startsWith("/")) {
|
||||
void handleCommand(text);
|
||||
return;
|
||||
}
|
||||
void sendMessage(text);
|
||||
};
|
||||
|
||||
client.onEvent = (evt) => {
|
||||
if (evt.event === "chat") handleChatEvent(evt.payload);
|
||||
};
|
||||
|
||||
client.onConnected = () => {
|
||||
setStatus("connected");
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
void loadHistory().then(() => {
|
||||
messages.addSystem("gateway connected");
|
||||
tui.requestRender();
|
||||
});
|
||||
} else {
|
||||
messages.addSystem("gateway reconnected");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onDisconnected = (reason) => {
|
||||
messages.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
||||
setStatus("disconnected");
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onGap = (info) => {
|
||||
messages.addSystem(`event gap: expected ${info.expected}, got ${info.received}`);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
updateHeader();
|
||||
setStatus("connecting");
|
||||
messages.addSystem("connecting...");
|
||||
tui.start();
|
||||
client.start();
|
||||
}
|
||||
Reference in New Issue
Block a user