332 lines
8.6 KiB
TypeScript
332 lines
8.6 KiB
TypeScript
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();
|
|
}
|