From 08ce608ae74b299d136ad261db55074a6ae5b29e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 04:46:04 +0100 Subject: [PATCH] feat: add gateway TUI --- docs/tui.md | 54 +++++++ package.json | 1 + pnpm-lock.yaml | 3 + src/cli/program.ts | 2 + src/cli/tui-cli.ts | 42 +++++ src/tui/gateway-chat.ts | 161 +++++++++++++++++++ src/tui/layout.ts | 40 +++++ src/tui/message-list.ts | 81 ++++++++++ src/tui/theme.ts | 28 ++++ src/tui/tui.ts | 331 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 743 insertions(+) create mode 100644 docs/tui.md create mode 100644 src/cli/tui-cli.ts create mode 100644 src/tui/gateway-chat.ts create mode 100644 src/tui/layout.ts create mode 100644 src/tui/message-list.ts create mode 100644 src/tui/theme.ts create mode 100644 src/tui/tui.ts diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 000000000..b59dfe79d --- /dev/null +++ b/docs/tui.md @@ -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 +``` +Use SSH tunneling or Tailscale to reach the Gateway WS. + +## Options +- `--url `: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`). +- `--token `: Gateway token (if required). +- `--password `: Gateway password (if required). +- `--session `: Session key (default: `session.mainKey` or `main`). +- `--deliver`: Deliver assistant replies to the provider. +- `--thinking `: Override thinking level for sends. +- `--timeout-ms `: Agent timeout in ms (default 30000). +- `--history-limit `: History entries to load (default 200). + +## Controls +- Enter: send message +- Esc: abort active run +- Ctrl+C: exit + +## Slash commands +- `/help` +- `/session ` +- `/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` diff --git a/package.json b/package.json index 6c4c78c61..a000aa07b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b7455c6b..fd37feca7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/cli/program.ts b/src/cli/program.ts index a6961d339..e3bcd3a01 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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); diff --git a/src/cli/tui-cli.ts b/src/cli/tui-cli.ts new file mode 100644 index 000000000..39ce4c481 --- /dev/null +++ b/src/cli/tui-cli.ts @@ -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 ", + "Gateway WebSocket URL (defaults to gateway.remote.url when configured)", + ) + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (if required)") + .option( + "--session ", + "Session key (default: session.mainKey from config)", + ) + .option("--deliver", "Deliver assistant replies", false) + .option("--thinking ", "Thinking level override") + .option("--timeout-ms ", "Agent timeout in ms", "30000") + .option("--history-limit ", "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); + } + }); +} diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts new file mode 100644 index 000000000..2c4ecfd7c --- /dev/null +++ b/src/tui/gateway-chat.ts @@ -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; + 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 }; +} diff --git a/src/tui/layout.ts b/src/tui/layout.ts new file mode 100644 index 000000000..aaccab9b1 --- /dev/null +++ b/src/tui/layout.ts @@ -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); + } +} diff --git a/src/tui/message-list.ts b/src/tui/message-list.ts new file mode 100644 index 000000000..57eb77323 --- /dev/null +++ b/src/tui/message-list.ts @@ -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(); + + 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)); + + } +} diff --git a/src/tui/theme.ts b/src/tui/theme.ts new file mode 100644 index 000000000..e40f9eb5c --- /dev/null +++ b/src/tui/theme.ts @@ -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), +}; diff --git a/src/tui/tui.ts b/src/tui/tui.ts new file mode 100644 index 000000000..10a072c23 --- /dev/null +++ b/src/tui/tui.ts @@ -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; + const content = Array.isArray(record.content) ? record.content : []; + const parts = content + .map((block) => { + if (!block || typeof block !== "object") return ""; + const b = block as Record; + 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; + 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 /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(); +}