mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 07:11:25 +00:00
feat: add gateway TUI
This commit is contained in:
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