feat: overhaul tui controller
This commit is contained in:
@@ -21,6 +21,6 @@ Updated: 2026-01-03
|
|||||||
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
|
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
|
||||||
- [x] Gateway TUI client: add session/model helpers + stricter typing.
|
- [x] Gateway TUI client: add session/model helpers + stricter typing.
|
||||||
- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors).
|
- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors).
|
||||||
- [ ] TUI controller: keybindings + Clawdis slash commands + history/stream wiring.
|
- [x] TUI controller: keybindings + Clawdis slash commands + history/stream wiring.
|
||||||
- [ ] Docs + changelog updated for the new TUI behavior.
|
- [ ] Docs + changelog updated for the new TUI behavior.
|
||||||
- [ ] Gate: lint, build, tests, docs list.
|
- [ ] Gate: lint, build, tests, docs list.
|
||||||
|
|||||||
88
src/tui/commands.ts
Normal file
88
src/tui/commands.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
|
||||||
|
const VERBOSE_LEVELS = ["on", "off"];
|
||||||
|
const ACTIVATION_LEVELS = ["mention", "always"];
|
||||||
|
const TOGGLE = ["on", "off"];
|
||||||
|
|
||||||
|
export type ParsedCommand = {
|
||||||
|
name: string;
|
||||||
|
args: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseCommand(input: string): ParsedCommand {
|
||||||
|
const trimmed = input.replace(/^\//, "").trim();
|
||||||
|
if (!trimmed) return { name: "", args: "" };
|
||||||
|
const [name, ...rest] = trimmed.split(/\s+/);
|
||||||
|
return { name: name.toLowerCase(), args: rest.join(" ").trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSlashCommands(): SlashCommand[] {
|
||||||
|
return [
|
||||||
|
{ name: "help", description: "Show slash command help" },
|
||||||
|
{ name: "status", description: "Show gateway status summary" },
|
||||||
|
{ name: "session", description: "Switch session (or open picker)" },
|
||||||
|
{ name: "sessions", description: "Open session picker" },
|
||||||
|
{
|
||||||
|
name: "model",
|
||||||
|
description: "Set model (or open picker)",
|
||||||
|
},
|
||||||
|
{ name: "models", description: "Open model picker" },
|
||||||
|
{
|
||||||
|
name: "think",
|
||||||
|
description: "Set thinking level",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "verbose",
|
||||||
|
description: "Set verbose on/off",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
VERBOSE_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "activation",
|
||||||
|
description: "Set group activation",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
ACTIVATION_LEVELS.filter((v) =>
|
||||||
|
v.startsWith(prefix.toLowerCase()),
|
||||||
|
).map((value) => ({ value, label: value })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deliver",
|
||||||
|
description: "Toggle delivery of assistant replies",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ name: "abort", description: "Abort active run" },
|
||||||
|
{ name: "new", description: "Reset the session" },
|
||||||
|
{ name: "reset", description: "Reset the session" },
|
||||||
|
{ name: "settings", description: "Open settings" },
|
||||||
|
{ name: "exit", description: "Exit the TUI" },
|
||||||
|
{ name: "quit", description: "Exit the TUI" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function helpText(): string {
|
||||||
|
return [
|
||||||
|
"Slash commands:",
|
||||||
|
"/help",
|
||||||
|
"/status",
|
||||||
|
"/session <key> (or /sessions)",
|
||||||
|
"/model <provider/model> (or /models)",
|
||||||
|
"/think <off|minimal|low|medium|high>",
|
||||||
|
"/verbose <on|off>",
|
||||||
|
"/activation <mention|always>",
|
||||||
|
"/deliver <on|off>",
|
||||||
|
"/new or /reset",
|
||||||
|
"/abort",
|
||||||
|
"/settings",
|
||||||
|
"/exit",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -157,6 +157,10 @@ export class GatewayChatClient {
|
|||||||
return await this.client.request("sessions.reset", { key });
|
return await this.client.request("sessions.reset", { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStatus() {
|
||||||
|
return await this.client.request("status");
|
||||||
|
}
|
||||||
|
|
||||||
async listModels(): Promise<
|
async listModels(): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import type { DefaultTextStyle, MarkdownTheme } from "@mariozechner/pi-tui";
|
|
||||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
|
||||||
import { theme } from "./theme.js";
|
|
||||||
|
|
||||||
type MessageRole = "user" | "system" | "tool";
|
|
||||||
|
|
||||||
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: MessageRole, text: string, style: DefaultTextStyle) {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { MarkdownTheme } from "@mariozechner/pi-tui";
|
|
||||||
import chalk from "chalk";
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
753
src/tui/tui.ts
753
src/tui/tui.ts
@@ -1,17 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
type Component,
|
CombinedAutocompleteProvider,
|
||||||
Input,
|
Container,
|
||||||
isCtrlC,
|
|
||||||
isEscape,
|
|
||||||
ProcessTerminal,
|
ProcessTerminal,
|
||||||
Text,
|
Text,
|
||||||
TUI,
|
TUI,
|
||||||
|
type Component,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { ChatLog } from "./components/chat-log.js";
|
||||||
|
import { CustomEditor } from "./components/custom-editor.js";
|
||||||
|
import { createSelectList, createSettingsList } from "./components/selectors.js";
|
||||||
|
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
|
||||||
import { GatewayChatClient } from "./gateway-chat.js";
|
import { GatewayChatClient } from "./gateway-chat.js";
|
||||||
import { ChatLayout } from "./layout.js";
|
import { editorTheme, theme } from "./theme/theme.js";
|
||||||
import { MessageList } from "./message-list.js";
|
|
||||||
import { markdownTheme, theme } from "./theme.js";
|
|
||||||
|
|
||||||
export type TuiOptions = {
|
export type TuiOptions = {
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -32,110 +33,79 @@ type ChatEvent = {
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class InputWrapper implements Component {
|
type AgentEvent = {
|
||||||
constructor(
|
runId: string;
|
||||||
private input: Input,
|
stream: string;
|
||||||
private onAbort: () => void,
|
data?: Record<string, unknown>;
|
||||||
private onExit: () => void,
|
};
|
||||||
) {}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
type SessionInfo = {
|
||||||
if (isCtrlC(data)) {
|
thinkingLevel?: string;
|
||||||
this.onExit();
|
verboseLevel?: string;
|
||||||
return;
|
model?: string;
|
||||||
|
contextTokens?: number | null;
|
||||||
|
totalTokens?: number | null;
|
||||||
|
updatedAt?: number | null;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractTextBlocks(
|
||||||
|
content: unknown,
|
||||||
|
opts?: { includeThinking?: boolean },
|
||||||
|
): string {
|
||||||
|
if (typeof content === "string") return content.trim();
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== "object") continue;
|
||||||
|
const record = block as Record<string, unknown>;
|
||||||
|
if (record.type === "text" && typeof record.text === "string") {
|
||||||
|
parts.push(record.text);
|
||||||
}
|
}
|
||||||
if (isEscape(data)) {
|
if (
|
||||||
this.onAbort();
|
opts?.includeThinking &&
|
||||||
return;
|
record.type === "thinking" &&
|
||||||
|
typeof record.thinking === "string"
|
||||||
|
) {
|
||||||
|
parts.push(`[thinking]\n${record.thinking}`);
|
||||||
}
|
}
|
||||||
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();
|
return parts.join("\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHistoryEntry(
|
function extractTextFromMessage(
|
||||||
entry: unknown,
|
message: unknown,
|
||||||
): { role: "user" | "assistant"; text: string } | null {
|
opts?: { includeThinking?: boolean },
|
||||||
if (!entry || typeof entry !== "object") return null;
|
): string {
|
||||||
const record = entry as Record<string, unknown>;
|
if (!message || typeof message !== "object") return "";
|
||||||
const role =
|
const record = message as Record<string, unknown>;
|
||||||
record.role === "user" || record.role === "assistant" ? record.role : null;
|
return extractTextBlocks(record.content, opts);
|
||||||
if (!role) return null;
|
}
|
||||||
const text = extractText(record);
|
|
||||||
if (!text) return null;
|
function formatTokens(total?: number | null, context?: number | null) {
|
||||||
return { role, text };
|
if (!total && !context) return "tokens ?";
|
||||||
|
if (!context) return `tokens ${total ?? 0}`;
|
||||||
|
const pct =
|
||||||
|
typeof total === "number" && context > 0
|
||||||
|
? Math.min(999, Math.round((total / context) * 100))
|
||||||
|
: null;
|
||||||
|
return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runTui(opts: TuiOptions) {
|
export async function runTui(opts: TuiOptions) {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const defaultSession =
|
const defaultSession =
|
||||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||||
let currentSession = defaultSession;
|
let currentSessionKey = defaultSession;
|
||||||
let activeRunId: string | null = null;
|
let currentSessionId: string | null = null;
|
||||||
let streamingMessageId: string | null = null;
|
let activeChatRunId: string | null = null;
|
||||||
let historyLoaded = false;
|
let historyLoaded = false;
|
||||||
|
let isConnected = false;
|
||||||
const messages = new MessageList(markdownTheme, {
|
let toolsExpanded = false;
|
||||||
user: { color: theme.user },
|
let showThinking = false;
|
||||||
assistant: { color: theme.assistant },
|
let deliverDefault = Boolean(opts.deliver);
|
||||||
system: { color: theme.system, italic: true },
|
let sessionInfo: SessionInfo = {};
|
||||||
tool: { color: theme.dim, italic: true },
|
let lastCtrlCAt = 0;
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
const client = new GatewayChatClient({
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
@@ -143,10 +113,28 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
password: opts.password,
|
password: opts.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const header = new Text("", 1, 0);
|
||||||
|
const status = new Text("", 1, 0);
|
||||||
|
const footer = new Text("", 1, 0);
|
||||||
|
const chatLog = new ChatLog();
|
||||||
|
const editor = new CustomEditor(editorTheme);
|
||||||
|
const overlay = new Container();
|
||||||
|
const root = new Container();
|
||||||
|
root.addChild(header);
|
||||||
|
root.addChild(overlay);
|
||||||
|
root.addChild(chatLog);
|
||||||
|
root.addChild(status);
|
||||||
|
root.addChild(footer);
|
||||||
|
root.addChild(editor);
|
||||||
|
|
||||||
|
const tui = new TUI(new ProcessTerminal());
|
||||||
|
tui.addChild(root);
|
||||||
|
tui.setFocus(editor);
|
||||||
|
|
||||||
const updateHeader = () => {
|
const updateHeader = () => {
|
||||||
header.setText(
|
header.setText(
|
||||||
theme.header(
|
theme.header(
|
||||||
`clawdis tui - ${client.connection.url} - session ${currentSession}`,
|
`clawdis tui - ${client.connection.url} - session ${currentSessionKey}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -155,121 +143,462 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
status.setText(theme.dim(text));
|
status.setText(theme.dim(text));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateFooter = () => {
|
||||||
|
const connection = isConnected ? "connected" : "disconnected";
|
||||||
|
const sessionLabel = sessionInfo.displayName
|
||||||
|
? `${currentSessionKey} (${sessionInfo.displayName})`
|
||||||
|
: currentSessionKey;
|
||||||
|
const modelLabel = sessionInfo.model ?? "unknown";
|
||||||
|
const tokens = formatTokens(
|
||||||
|
sessionInfo.totalTokens ?? null,
|
||||||
|
sessionInfo.contextTokens ?? null,
|
||||||
|
);
|
||||||
|
const think = sessionInfo.thinkingLevel ?? "off";
|
||||||
|
const verbose = sessionInfo.verboseLevel ?? "off";
|
||||||
|
const deliver = deliverDefault ? "on" : "off";
|
||||||
|
footer.setText(
|
||||||
|
theme.dim(
|
||||||
|
`${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | ${tokens} | deliver ${deliver}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeOverlay = () => {
|
||||||
|
overlay.clear();
|
||||||
|
tui.setFocus(editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openOverlay = (component: Component) => {
|
||||||
|
overlay.clear();
|
||||||
|
overlay.addChild(component);
|
||||||
|
tui.setFocus(component);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSessionInfo = async () => {
|
||||||
|
try {
|
||||||
|
const result = await client.listSessions({
|
||||||
|
includeGlobal: false,
|
||||||
|
includeUnknown: false,
|
||||||
|
});
|
||||||
|
const entry = result.sessions.find((row) => row.key === currentSessionKey);
|
||||||
|
sessionInfo = {
|
||||||
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
model: entry?.model ?? result.defaults?.model ?? undefined,
|
||||||
|
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
|
||||||
|
totalTokens: entry?.totalTokens ?? null,
|
||||||
|
updatedAt: entry?.updatedAt ?? null,
|
||||||
|
displayName: entry?.displayName,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
updateFooter();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
const loadHistory = async () => {
|
const loadHistory = async () => {
|
||||||
try {
|
try {
|
||||||
const history = await client.loadHistory({
|
const history = await client.loadHistory({
|
||||||
sessionKey: currentSession,
|
sessionKey: currentSessionKey,
|
||||||
limit: opts.historyLimit ?? 200,
|
limit: opts.historyLimit ?? 200,
|
||||||
});
|
});
|
||||||
const historyRecord = history as { messages?: unknown[] } | undefined;
|
const record = history as {
|
||||||
messages.clearAll();
|
messages?: unknown[];
|
||||||
messages.addSystem(`session ${currentSession}`);
|
sessionId?: string;
|
||||||
for (const entry of historyRecord?.messages ?? []) {
|
thinkingLevel?: string;
|
||||||
const parsed = renderHistoryEntry(entry);
|
};
|
||||||
if (!parsed) continue;
|
currentSessionId =
|
||||||
if (parsed.role === "user") messages.addUser(parsed.text);
|
typeof record.sessionId === "string" ? record.sessionId : null;
|
||||||
if (parsed.role === "assistant") messages.addAssistant(parsed.text);
|
sessionInfo.thinkingLevel = record.thinkingLevel ?? sessionInfo.thinkingLevel;
|
||||||
|
chatLog.clearAll();
|
||||||
|
chatLog.addSystem(`session ${currentSessionKey}`);
|
||||||
|
for (const entry of record.messages ?? []) {
|
||||||
|
if (!entry || typeof entry !== "object") continue;
|
||||||
|
const message = entry as Record<string, unknown>;
|
||||||
|
if (message.role === "user") {
|
||||||
|
const text = extractTextFromMessage(message);
|
||||||
|
if (text) chatLog.addUser(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const text = extractTextFromMessage(message, {
|
||||||
|
includeThinking: showThinking,
|
||||||
|
});
|
||||||
|
if (text) chatLog.finalizeAssistant(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.role === "toolResult") {
|
||||||
|
const toolCallId = String(message.toolCallId ?? "");
|
||||||
|
const toolName = String(message.toolName ?? "tool");
|
||||||
|
const component = chatLog.startTool(toolCallId, toolName, {});
|
||||||
|
component.setResult(
|
||||||
|
{
|
||||||
|
content: Array.isArray(message.content)
|
||||||
|
? (message.content as Record<string, unknown>[])
|
||||||
|
: [],
|
||||||
|
details:
|
||||||
|
typeof message.details === "object" && message.details
|
||||||
|
? (message.details as Record<string, unknown>)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{ isError: Boolean(message.isError) },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
historyLoaded = true;
|
historyLoaded = true;
|
||||||
tui.requestRender();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
messages.addSystem(`history failed: ${String(err)}`);
|
chatLog.addSystem(`history failed: ${String(err)}`);
|
||||||
tui.requestRender();
|
|
||||||
}
|
}
|
||||||
|
await refreshSessionInfo();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSession = async (key: string) => {
|
||||||
|
currentSessionKey = key;
|
||||||
|
activeChatRunId = null;
|
||||||
|
currentSessionId = null;
|
||||||
|
historyLoaded = false;
|
||||||
|
updateHeader();
|
||||||
|
await loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortActive = async () => {
|
||||||
|
if (!activeChatRunId) {
|
||||||
|
chatLog.addSystem("no active run");
|
||||||
|
tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.abortChat({
|
||||||
|
sessionKey: currentSessionKey,
|
||||||
|
runId: activeChatRunId,
|
||||||
|
});
|
||||||
|
setStatus("aborted");
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`abort failed: ${String(err)}`);
|
||||||
|
setStatus("abort failed");
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChatEvent = (payload: unknown) => {
|
const handleChatEvent = (payload: unknown) => {
|
||||||
if (!payload || typeof payload !== "object") return;
|
if (!payload || typeof payload !== "object") return;
|
||||||
const evt = payload as ChatEvent;
|
const evt = payload as ChatEvent;
|
||||||
if (evt.sessionKey !== currentSession) return;
|
if (evt.sessionKey !== currentSessionKey) return;
|
||||||
|
|
||||||
if (evt.state === "delta") {
|
if (evt.state === "delta") {
|
||||||
const text = extractText(evt.message);
|
const text = extractTextFromMessage(evt.message, {
|
||||||
|
includeThinking: showThinking,
|
||||||
|
});
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (!streamingMessageId || activeRunId !== evt.runId) {
|
chatLog.updateAssistant(text, evt.runId);
|
||||||
streamingMessageId = messages.addAssistant(text, evt.runId);
|
|
||||||
activeRunId = evt.runId;
|
|
||||||
} else {
|
|
||||||
messages.updateAssistant(streamingMessageId, text);
|
|
||||||
}
|
|
||||||
setStatus("streaming");
|
setStatus("streaming");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.state === "final") {
|
if (evt.state === "final") {
|
||||||
const text = extractText(evt.message);
|
const text = extractTextFromMessage(evt.message, {
|
||||||
if (streamingMessageId && activeRunId === evt.runId) {
|
includeThinking: showThinking,
|
||||||
messages.updateAssistant(streamingMessageId, text || "(no output)");
|
});
|
||||||
} else if (text) {
|
chatLog.finalizeAssistant(text || "(no output)", evt.runId);
|
||||||
messages.addAssistant(text, evt.runId);
|
activeChatRunId = null;
|
||||||
}
|
|
||||||
activeRunId = null;
|
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.state === "aborted") {
|
if (evt.state === "aborted") {
|
||||||
messages.addSystem("run aborted");
|
chatLog.addSystem("run aborted");
|
||||||
activeRunId = null;
|
activeChatRunId = null;
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("aborted");
|
setStatus("aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.state === "error") {
|
if (evt.state === "error") {
|
||||||
messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||||
activeRunId = null;
|
activeChatRunId = null;
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentEvent = (payload: unknown) => {
|
||||||
|
if (!payload || typeof payload !== "object") return;
|
||||||
|
const evt = payload as AgentEvent;
|
||||||
|
if (!currentSessionId || evt.runId !== currentSessionId) return;
|
||||||
|
if (evt.stream === "tool") {
|
||||||
|
const data = evt.data ?? {};
|
||||||
|
const phase = String(data.phase ?? "");
|
||||||
|
const toolCallId = String(data.toolCallId ?? "");
|
||||||
|
const toolName = String(data.name ?? "tool");
|
||||||
|
if (!toolCallId) return;
|
||||||
|
if (phase === "start") {
|
||||||
|
chatLog.startTool(toolCallId, toolName, data.args);
|
||||||
|
} else if (phase === "update") {
|
||||||
|
chatLog.updateToolResult(toolCallId, data.partialResult, {
|
||||||
|
partial: true,
|
||||||
|
});
|
||||||
|
} else if (phase === "result") {
|
||||||
|
chatLog.updateToolResult(toolCallId, data.result, {
|
||||||
|
isError: Boolean(data.isError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.stream === "job") {
|
||||||
|
const state = typeof evt.data?.state === "string" ? evt.data.state : "";
|
||||||
|
if (state === "started") setStatus("running");
|
||||||
|
if (state === "done") setStatus("idle");
|
||||||
|
if (state === "error") setStatus("error");
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModelSelector = async () => {
|
||||||
|
try {
|
||||||
|
const models = await client.listModels();
|
||||||
|
if (models.length === 0) {
|
||||||
|
chatLog.addSystem("no models available");
|
||||||
|
tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = models.map((model) => ({
|
||||||
|
value: `${model.provider}/${model.id}`,
|
||||||
|
label: `${model.provider}/${model.id}`,
|
||||||
|
description: model.name && model.name !== model.id ? model.name : "",
|
||||||
|
}));
|
||||||
|
const selector = createSelectList(items, 9);
|
||||||
|
selector.onSelect = (item) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: currentSessionKey,
|
||||||
|
model: item.value,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`model set to ${item.value}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
closeOverlay();
|
||||||
|
tui.requestRender();
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
selector.onCancel = () => {
|
||||||
|
closeOverlay();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
openOverlay(selector);
|
||||||
|
tui.requestRender();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`model list failed: ${String(err)}`);
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSessionSelector = async () => {
|
||||||
|
try {
|
||||||
|
const result = await client.listSessions({
|
||||||
|
includeGlobal: false,
|
||||||
|
includeUnknown: false,
|
||||||
|
});
|
||||||
|
const items = result.sessions.map((session) => ({
|
||||||
|
value: session.key,
|
||||||
|
label: session.displayName ?? session.key,
|
||||||
|
description: session.updatedAt
|
||||||
|
? new Date(session.updatedAt).toLocaleString()
|
||||||
|
: "",
|
||||||
|
}));
|
||||||
|
const selector = createSelectList(items, 9);
|
||||||
|
selector.onSelect = (item) => {
|
||||||
|
void (async () => {
|
||||||
|
closeOverlay();
|
||||||
|
await setSession(item.value);
|
||||||
|
tui.requestRender();
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
selector.onCancel = () => {
|
||||||
|
closeOverlay();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
openOverlay(selector);
|
||||||
|
tui.requestRender();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: "deliver",
|
||||||
|
label: "Deliver replies",
|
||||||
|
currentValue: deliverDefault ? "on" : "off",
|
||||||
|
values: ["off", "on"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tools",
|
||||||
|
label: "Tool output",
|
||||||
|
currentValue: toolsExpanded ? "expanded" : "collapsed",
|
||||||
|
values: ["collapsed", "expanded"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "thinking",
|
||||||
|
label: "Show thinking",
|
||||||
|
currentValue: showThinking ? "on" : "off",
|
||||||
|
values: ["off", "on"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const settings = createSettingsList(
|
||||||
|
items,
|
||||||
|
(id, value) => {
|
||||||
|
if (id === "deliver") {
|
||||||
|
deliverDefault = value === "on";
|
||||||
|
updateFooter();
|
||||||
|
}
|
||||||
|
if (id === "tools") {
|
||||||
|
toolsExpanded = value === "expanded";
|
||||||
|
chatLog.setToolsExpanded(toolsExpanded);
|
||||||
|
}
|
||||||
|
if (id === "thinking") {
|
||||||
|
showThinking = value === "on";
|
||||||
|
void loadHistory();
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
closeOverlay();
|
||||||
|
tui.requestRender();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
openOverlay(settings);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommand = async (raw: string) => {
|
const handleCommand = async (raw: string) => {
|
||||||
const [command, ...rest] = raw.slice(1).trim().split(/\s+/);
|
const { name, args } = parseCommand(raw);
|
||||||
const arg = rest.join(" ").trim();
|
if (!name) return;
|
||||||
switch (command) {
|
switch (name) {
|
||||||
case "help": {
|
case "help":
|
||||||
messages.addSystem("/help /session <key> /abort /exit");
|
chatLog.addSystem(helpText());
|
||||||
break;
|
break;
|
||||||
}
|
case "status":
|
||||||
case "session": {
|
try {
|
||||||
if (!arg) {
|
const status = await client.getStatus();
|
||||||
messages.addSystem("missing session key");
|
chatLog.addSystem(
|
||||||
|
typeof status === "string"
|
||||||
|
? status
|
||||||
|
: JSON.stringify(status, null, 2),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`status failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "session":
|
||||||
|
if (!args) {
|
||||||
|
await openSessionSelector();
|
||||||
|
} else {
|
||||||
|
await setSession(args);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "sessions":
|
||||||
|
await openSessionSelector();
|
||||||
|
break;
|
||||||
|
case "model":
|
||||||
|
if (!args) {
|
||||||
|
await openModelSelector();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: currentSessionKey,
|
||||||
|
model: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`model set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "models":
|
||||||
|
await openModelSelector();
|
||||||
|
break;
|
||||||
|
case "think":
|
||||||
|
if (!args) {
|
||||||
|
chatLog.addSystem("usage: /think <off|minimal|low|medium|high>");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
currentSession = arg;
|
try {
|
||||||
activeRunId = null;
|
await client.patchSession({
|
||||||
streamingMessageId = null;
|
key: currentSessionKey,
|
||||||
historyLoaded = false;
|
thinkingLevel: args,
|
||||||
updateHeader();
|
});
|
||||||
await loadHistory();
|
chatLog.addSystem(`thinking set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`think failed: ${String(err)}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case "verbose":
|
||||||
case "abort": {
|
if (!args) {
|
||||||
if (!activeRunId) {
|
chatLog.addSystem("usage: /verbose <on|off>");
|
||||||
messages.addSystem("no active run");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await client.abortChat({
|
try {
|
||||||
sessionKey: currentSession,
|
await client.patchSession({
|
||||||
runId: activeRunId,
|
key: currentSessionKey,
|
||||||
});
|
verboseLevel: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`verbose set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case "activation":
|
||||||
case "exit": {
|
if (!args) {
|
||||||
|
chatLog.addSystem("usage: /activation <mention|always>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: currentSessionKey,
|
||||||
|
groupActivation: args === "always" ? "always" : "mention",
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`activation set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`activation failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "deliver":
|
||||||
|
if (!args) {
|
||||||
|
chatLog.addSystem("usage: /deliver <on|off>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
deliverDefault = args === "on";
|
||||||
|
updateFooter();
|
||||||
|
chatLog.addSystem(`deliver ${deliverDefault ? "on" : "off"}`);
|
||||||
|
break;
|
||||||
|
case "new":
|
||||||
|
case "reset":
|
||||||
|
try {
|
||||||
|
await client.resetSession(currentSessionKey);
|
||||||
|
chatLog.addSystem(`session ${currentSessionKey} reset`);
|
||||||
|
await loadHistory();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`reset failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "abort":
|
||||||
|
await abortActive();
|
||||||
|
break;
|
||||||
|
case "settings":
|
||||||
|
openSettings();
|
||||||
|
break;
|
||||||
|
case "exit":
|
||||||
|
case "quit":
|
||||||
client.stop();
|
client.stop();
|
||||||
tui.stop();
|
tui.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
case "quit": {
|
|
||||||
client.stop();
|
|
||||||
tui.stop();
|
|
||||||
process.exit(0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
messages.addSystem(`unknown command: /${command}`);
|
chatLog.addSystem(`unknown command: /${name}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
@@ -277,63 +606,112 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
|
|
||||||
const sendMessage = async (text: string) => {
|
const sendMessage = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
messages.addUser(text);
|
chatLog.addUser(text);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
setStatus("sending");
|
setStatus("sending");
|
||||||
const { runId } = await client.sendChat({
|
const { runId } = await client.sendChat({
|
||||||
sessionKey: currentSession,
|
sessionKey: currentSessionKey,
|
||||||
message: text,
|
message: text,
|
||||||
thinking: opts.thinking,
|
thinking: opts.thinking,
|
||||||
deliver: opts.deliver,
|
deliver: deliverDefault,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
activeRunId = runId;
|
activeChatRunId = runId;
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("waiting");
|
setStatus("waiting");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
messages.addSystem(`send failed: ${String(err)}`);
|
chatLog.addSystem(`send failed: ${String(err)}`);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
input.onSubmit = (value) => {
|
editor.setAutocompleteProvider(
|
||||||
const text = value.trim();
|
new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()),
|
||||||
input.setValue("");
|
);
|
||||||
if (!text) return;
|
editor.onSubmit = (text) => {
|
||||||
if (text.startsWith("/")) {
|
const value = text.trim();
|
||||||
void handleCommand(text);
|
editor.setText("");
|
||||||
|
if (!value) return;
|
||||||
|
if (value.startsWith("/")) {
|
||||||
|
void handleCommand(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void sendMessage(text);
|
void sendMessage(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.onEscape = () => {
|
||||||
|
void abortActive();
|
||||||
|
};
|
||||||
|
editor.onCtrlC = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (editor.getText().trim().length > 0) {
|
||||||
|
editor.setText("");
|
||||||
|
setStatus("cleared input");
|
||||||
|
tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (now - lastCtrlCAt < 1000) {
|
||||||
|
client.stop();
|
||||||
|
tui.stop();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
lastCtrlCAt = now;
|
||||||
|
setStatus("press ctrl+c again to exit");
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
editor.onCtrlD = () => {
|
||||||
|
client.stop();
|
||||||
|
tui.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
editor.onCtrlO = () => {
|
||||||
|
toolsExpanded = !toolsExpanded;
|
||||||
|
chatLog.setToolsExpanded(toolsExpanded);
|
||||||
|
setStatus(toolsExpanded ? "tools expanded" : "tools collapsed");
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
editor.onCtrlL = () => {
|
||||||
|
void openModelSelector();
|
||||||
|
};
|
||||||
|
editor.onCtrlP = () => {
|
||||||
|
void openSessionSelector();
|
||||||
|
};
|
||||||
|
editor.onCtrlT = () => {
|
||||||
|
showThinking = !showThinking;
|
||||||
|
void loadHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
client.onEvent = (evt) => {
|
client.onEvent = (evt) => {
|
||||||
if (evt.event === "chat") handleChatEvent(evt.payload);
|
if (evt.event === "chat") handleChatEvent(evt.payload);
|
||||||
|
if (evt.event === "agent") handleAgentEvent(evt.payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
client.onConnected = () => {
|
client.onConnected = () => {
|
||||||
|
isConnected = true;
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
updateHeader();
|
updateHeader();
|
||||||
if (!historyLoaded) {
|
if (!historyLoaded) {
|
||||||
void loadHistory().then(() => {
|
void loadHistory().then(() => {
|
||||||
messages.addSystem("gateway connected");
|
chatLog.addSystem("gateway connected");
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
messages.addSystem("gateway reconnected");
|
chatLog.addSystem("gateway reconnected");
|
||||||
}
|
}
|
||||||
|
updateFooter();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
client.onDisconnected = (reason) => {
|
client.onDisconnected = (reason) => {
|
||||||
messages.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
isConnected = false;
|
||||||
|
chatLog.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
|
updateFooter();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
client.onGap = (info) => {
|
client.onGap = (info) => {
|
||||||
messages.addSystem(
|
chatLog.addSystem(
|
||||||
`event gap: expected ${info.expected}, got ${info.received}`,
|
`event gap: expected ${info.expected}, got ${info.received}`,
|
||||||
);
|
);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
@@ -341,7 +719,8 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
|
|
||||||
updateHeader();
|
updateHeader();
|
||||||
setStatus("connecting");
|
setStatus("connecting");
|
||||||
messages.addSystem("connecting...");
|
updateFooter();
|
||||||
|
chatLog.addSystem("connecting...");
|
||||||
tui.start();
|
tui.start();
|
||||||
client.start();
|
client.start();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user