feat: add gateway TUI

This commit is contained in:
Peter Steinberger
2026-01-03 04:46:04 +01:00
parent 928631309e
commit 08ce608ae7
10 changed files with 743 additions and 0 deletions

54
docs/tui.md Normal file
View 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`

View File

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

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

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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();
}