2669 lines
82 KiB
JavaScript
2669 lines
82 KiB
JavaScript
import { W as resolveGatewayPort, f as visibleWidth } from "./entry.js";
|
|
import { H as parseAgentSessionKey, N as normalizeAgentId, P as normalizeMainKey, c as resolveDefaultAgentId, k as buildAgentMainSessionKey } from "./agent-scope-jm0ZdXwM.js";
|
|
import { M as VERSION, r as loadConfig } from "./config-DxJyvt4o.js";
|
|
import { St as PROTOCOL_VERSION, t as GatewayClient } from "./client-BYx0ZoAv.js";
|
|
import { a as resolveExplicitGatewayAuth, r as ensureExplicitGatewayAuth } from "./call-BDMBg5WA.js";
|
|
import { f as GATEWAY_CLIENT_CAPS, h as GATEWAY_CLIENT_NAMES, m as GATEWAY_CLIENT_MODES } from "./message-channel-Csfn34Sf.js";
|
|
import { M as formatThinkingLevels, P as listThinkingLevelLabels, V as resolveResponseUsageMode, z as normalizeUsageDisplay } from "./pi-embedded-helpers-wpP9rGyv.js";
|
|
import { a as extractContentFromMessage, c as formatContextUsageLine, d as resolveFinalAssistantText, i as composeThinkingAndContent, l as formatTokens, n as formatAge, o as extractTextFromMessage, p as formatTokenCount, r as asString, s as extractThinkingFromMessage, u as isCommandMessage, v as listChatCommands, y as listChatCommandsForConfig } from "./channel-summary-CgnCeeeN.js";
|
|
import { n as resolveToolDisplay, t as formatToolDetail } from "./tool-display-BMYWrp0L.js";
|
|
import { spawn } from "node:child_process";
|
|
import chalk from "chalk";
|
|
import { randomUUID } from "node:crypto";
|
|
import { Box, CombinedAutocompleteProvider, Container, Editor, Input, Key, Loader, Markdown, ProcessTerminal, SelectList, SettingsList, Spacer, TUI, Text, getEditorKeybindings, isKeyRelease, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
import { highlight, supportsLanguage } from "cli-highlight";
|
|
|
|
//#region src/tui/commands.ts
|
|
const VERBOSE_LEVELS = ["on", "off"];
|
|
const REASONING_LEVELS = ["on", "off"];
|
|
const ELEVATED_LEVELS = [
|
|
"on",
|
|
"off",
|
|
"ask",
|
|
"full"
|
|
];
|
|
const ACTIVATION_LEVELS = ["mention", "always"];
|
|
const USAGE_FOOTER_LEVELS = [
|
|
"off",
|
|
"tokens",
|
|
"full"
|
|
];
|
|
const COMMAND_ALIASES = { elev: "elevated" };
|
|
function parseCommand(input) {
|
|
const trimmed = input.replace(/^\//, "").trim();
|
|
if (!trimmed) return {
|
|
name: "",
|
|
args: ""
|
|
};
|
|
const [name, ...rest] = trimmed.split(/\s+/);
|
|
const normalized = name.toLowerCase();
|
|
return {
|
|
name: COMMAND_ALIASES[normalized] ?? normalized,
|
|
args: rest.join(" ").trim()
|
|
};
|
|
}
|
|
function getSlashCommands(options = {}) {
|
|
const thinkLevels = listThinkingLevelLabels(options.provider, options.model);
|
|
const commands = [
|
|
{
|
|
name: "help",
|
|
description: "Show slash command help"
|
|
},
|
|
{
|
|
name: "status",
|
|
description: "Show gateway status summary"
|
|
},
|
|
{
|
|
name: "agent",
|
|
description: "Switch agent (or open picker)"
|
|
},
|
|
{
|
|
name: "agents",
|
|
description: "Open agent picker"
|
|
},
|
|
{
|
|
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) => thinkLevels.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: "reasoning",
|
|
description: "Set reasoning on/off",
|
|
getArgumentCompletions: (prefix) => REASONING_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
|
|
value,
|
|
label: value
|
|
}))
|
|
},
|
|
{
|
|
name: "usage",
|
|
description: "Toggle per-response usage line",
|
|
getArgumentCompletions: (prefix) => USAGE_FOOTER_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
|
|
value,
|
|
label: value
|
|
}))
|
|
},
|
|
{
|
|
name: "elevated",
|
|
description: "Set elevated on/off/ask/full",
|
|
getArgumentCompletions: (prefix) => ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
|
|
value,
|
|
label: value
|
|
}))
|
|
},
|
|
{
|
|
name: "elev",
|
|
description: "Alias for /elevated",
|
|
getArgumentCompletions: (prefix) => ELEVATED_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: "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"
|
|
}
|
|
];
|
|
const seen = new Set(commands.map((command) => command.name));
|
|
const gatewayCommands = options.cfg ? listChatCommandsForConfig(options.cfg) : listChatCommands();
|
|
for (const command of gatewayCommands) {
|
|
const aliases = command.textAliases.length > 0 ? command.textAliases : [`/${command.key}`];
|
|
for (const alias of aliases) {
|
|
const name = alias.replace(/^\//, "").trim();
|
|
if (!name || seen.has(name)) continue;
|
|
seen.add(name);
|
|
commands.push({
|
|
name,
|
|
description: command.description
|
|
});
|
|
}
|
|
}
|
|
return commands;
|
|
}
|
|
function helpText(options = {}) {
|
|
return [
|
|
"Slash commands:",
|
|
"/help",
|
|
"/commands",
|
|
"/status",
|
|
"/agent <id> (or /agents)",
|
|
"/session <key> (or /sessions)",
|
|
"/model <provider/model> (or /models)",
|
|
`/think <${formatThinkingLevels(options.provider, options.model, "|")}>`,
|
|
"/verbose <on|off>",
|
|
"/reasoning <on|off>",
|
|
"/usage <off|tokens|full>",
|
|
"/elevated <on|off|ask|full>",
|
|
"/elev <on|off|ask|full>",
|
|
"/activation <mention|always>",
|
|
"/new or /reset",
|
|
"/abort",
|
|
"/settings",
|
|
"/exit"
|
|
].join("\n");
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/theme/syntax-theme.ts
|
|
/**
|
|
* Syntax highlighting theme for code blocks.
|
|
* Uses chalk functions to style different token types.
|
|
*/
|
|
function createSyntaxTheme(fallback) {
|
|
return {
|
|
keyword: chalk.hex("#C586C0"),
|
|
built_in: chalk.hex("#4EC9B0"),
|
|
type: chalk.hex("#4EC9B0"),
|
|
literal: chalk.hex("#569CD6"),
|
|
number: chalk.hex("#B5CEA8"),
|
|
string: chalk.hex("#CE9178"),
|
|
regexp: chalk.hex("#D16969"),
|
|
symbol: chalk.hex("#B5CEA8"),
|
|
class: chalk.hex("#4EC9B0"),
|
|
function: chalk.hex("#DCDCAA"),
|
|
title: chalk.hex("#DCDCAA"),
|
|
params: chalk.hex("#9CDCFE"),
|
|
comment: chalk.hex("#6A9955"),
|
|
doctag: chalk.hex("#608B4E"),
|
|
meta: chalk.hex("#9CDCFE"),
|
|
"meta-keyword": chalk.hex("#C586C0"),
|
|
"meta-string": chalk.hex("#CE9178"),
|
|
section: chalk.hex("#DCDCAA"),
|
|
tag: chalk.hex("#569CD6"),
|
|
name: chalk.hex("#9CDCFE"),
|
|
attr: chalk.hex("#9CDCFE"),
|
|
attribute: chalk.hex("#9CDCFE"),
|
|
variable: chalk.hex("#9CDCFE"),
|
|
bullet: chalk.hex("#D7BA7D"),
|
|
code: chalk.hex("#CE9178"),
|
|
emphasis: chalk.italic,
|
|
strong: chalk.bold,
|
|
formula: chalk.hex("#C586C0"),
|
|
link: chalk.hex("#4EC9B0"),
|
|
quote: chalk.hex("#6A9955"),
|
|
addition: chalk.hex("#B5CEA8"),
|
|
deletion: chalk.hex("#F44747"),
|
|
"selector-tag": chalk.hex("#D7BA7D"),
|
|
"selector-id": chalk.hex("#D7BA7D"),
|
|
"selector-class": chalk.hex("#D7BA7D"),
|
|
"selector-attr": chalk.hex("#D7BA7D"),
|
|
"selector-pseudo": chalk.hex("#D7BA7D"),
|
|
"template-tag": chalk.hex("#C586C0"),
|
|
"template-variable": chalk.hex("#9CDCFE"),
|
|
default: fallback
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/theme/theme.ts
|
|
const palette = {
|
|
text: "#E8E3D5",
|
|
dim: "#7B7F87",
|
|
accent: "#F6C453",
|
|
accentSoft: "#F2A65A",
|
|
border: "#3C414B",
|
|
userBg: "#2B2F36",
|
|
userText: "#F3EEE0",
|
|
systemText: "#9BA3B2",
|
|
toolPendingBg: "#1F2A2F",
|
|
toolSuccessBg: "#1E2D23",
|
|
toolErrorBg: "#2F1F1F",
|
|
toolTitle: "#F6C453",
|
|
toolOutput: "#E1DACB",
|
|
quote: "#8CC8FF",
|
|
quoteBorder: "#3B4D6B",
|
|
code: "#F0C987",
|
|
codeBlock: "#1E232A",
|
|
codeBorder: "#343A45",
|
|
link: "#7DD3A5",
|
|
error: "#F97066",
|
|
success: "#7DD3A5"
|
|
};
|
|
const fg = (hex) => (text) => chalk.hex(hex)(text);
|
|
const bg = (hex) => (text) => chalk.bgHex(hex)(text);
|
|
const syntaxTheme = createSyntaxTheme(fg(palette.code));
|
|
/**
|
|
* Highlight code with syntax coloring.
|
|
* Returns an array of lines with ANSI escape codes.
|
|
*/
|
|
function highlightCode(code, lang) {
|
|
try {
|
|
return highlight(code, {
|
|
language: lang && supportsLanguage(lang) ? lang : void 0,
|
|
theme: syntaxTheme,
|
|
ignoreIllegals: true
|
|
}).split("\n");
|
|
} catch {
|
|
return code.split("\n").map((line) => fg(palette.code)(line));
|
|
}
|
|
}
|
|
const theme = {
|
|
fg: fg(palette.text),
|
|
dim: fg(palette.dim),
|
|
accent: fg(palette.accent),
|
|
accentSoft: fg(palette.accentSoft),
|
|
success: fg(palette.success),
|
|
error: fg(palette.error),
|
|
header: (text) => chalk.bold(fg(palette.accent)(text)),
|
|
system: fg(palette.systemText),
|
|
userBg: bg(palette.userBg),
|
|
userText: fg(palette.userText),
|
|
toolTitle: fg(palette.toolTitle),
|
|
toolOutput: fg(palette.toolOutput),
|
|
toolPendingBg: bg(palette.toolPendingBg),
|
|
toolSuccessBg: bg(palette.toolSuccessBg),
|
|
toolErrorBg: bg(palette.toolErrorBg),
|
|
border: fg(palette.border),
|
|
bold: (text) => chalk.bold(text),
|
|
italic: (text) => chalk.italic(text)
|
|
};
|
|
const markdownTheme = {
|
|
heading: (text) => chalk.bold(fg(palette.accent)(text)),
|
|
link: (text) => fg(palette.link)(text),
|
|
linkUrl: (text) => chalk.dim(text),
|
|
code: (text) => fg(palette.code)(text),
|
|
codeBlock: (text) => fg(palette.code)(text),
|
|
codeBlockBorder: (text) => fg(palette.codeBorder)(text),
|
|
quote: (text) => fg(palette.quote)(text),
|
|
quoteBorder: (text) => fg(palette.quoteBorder)(text),
|
|
hr: (text) => fg(palette.border)(text),
|
|
listBullet: (text) => fg(palette.accentSoft)(text),
|
|
bold: (text) => chalk.bold(text),
|
|
italic: (text) => chalk.italic(text),
|
|
strikethrough: (text) => chalk.strikethrough(text),
|
|
underline: (text) => chalk.underline(text),
|
|
highlightCode
|
|
};
|
|
const selectListTheme = {
|
|
selectedPrefix: (text) => fg(palette.accent)(text),
|
|
selectedText: (text) => chalk.bold(fg(palette.accent)(text)),
|
|
description: (text) => fg(palette.dim)(text),
|
|
scrollInfo: (text) => fg(palette.dim)(text),
|
|
noMatch: (text) => fg(palette.dim)(text)
|
|
};
|
|
const filterableSelectListTheme = {
|
|
...selectListTheme,
|
|
filterLabel: (text) => fg(palette.dim)(text)
|
|
};
|
|
const settingsListTheme = {
|
|
label: (text, selected) => selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
|
|
value: (text, selected) => selected ? fg(palette.accentSoft)(text) : fg(palette.dim)(text),
|
|
description: (text) => fg(palette.systemText)(text),
|
|
cursor: fg(palette.accent)("→ "),
|
|
hint: (text) => fg(palette.dim)(text)
|
|
};
|
|
const editorTheme = {
|
|
borderColor: (text) => fg(palette.border)(text),
|
|
selectList: selectListTheme
|
|
};
|
|
const searchableSelectListTheme = {
|
|
selectedPrefix: (text) => fg(palette.accent)(text),
|
|
selectedText: (text) => chalk.bold(fg(palette.accent)(text)),
|
|
description: (text) => fg(palette.dim)(text),
|
|
scrollInfo: (text) => fg(palette.dim)(text),
|
|
noMatch: (text) => fg(palette.dim)(text),
|
|
searchPrompt: (text) => fg(palette.accentSoft)(text),
|
|
searchInput: (text) => fg(palette.text)(text),
|
|
matchHighlight: (text) => chalk.bold(fg(palette.accent)(text))
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/components/assistant-message.ts
|
|
var AssistantMessageComponent = class extends Container {
|
|
constructor(text) {
|
|
super();
|
|
this.body = new Markdown(text, 1, 0, markdownTheme, { color: (line) => theme.fg(line) });
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(this.body);
|
|
}
|
|
setText(text) {
|
|
this.body.setText(text);
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/components/tool-execution.ts
|
|
const PREVIEW_LINES = 12;
|
|
function formatArgs(toolName, args) {
|
|
const detail = formatToolDetail(resolveToolDisplay({
|
|
name: toolName,
|
|
args
|
|
}));
|
|
if (detail) return detail;
|
|
if (!args || typeof args !== "object") return "";
|
|
try {
|
|
return JSON.stringify(args);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
function extractText(result) {
|
|
if (!result?.content) return "";
|
|
const lines = [];
|
|
for (const entry of result.content) if (entry.type === "text" && entry.text) lines.push(entry.text);
|
|
else if (entry.type === "image") {
|
|
const mime = entry.mimeType ?? "image";
|
|
const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : "";
|
|
const omitted = entry.omitted ? " (omitted)" : "";
|
|
lines.push(`[${mime}${size}${omitted}]`);
|
|
}
|
|
return lines.join("\n").trim();
|
|
}
|
|
var ToolExecutionComponent = class extends Container {
|
|
constructor(toolName, args) {
|
|
super();
|
|
this.expanded = false;
|
|
this.isError = false;
|
|
this.isPartial = true;
|
|
this.toolName = toolName;
|
|
this.args = args;
|
|
this.box = new Box(1, 1, (line) => theme.toolPendingBg(line));
|
|
this.header = new Text("", 0, 0);
|
|
this.argsLine = new Text("", 0, 0);
|
|
this.output = new Markdown("", 0, 0, markdownTheme, { color: (line) => theme.toolOutput(line) });
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(this.box);
|
|
this.box.addChild(this.header);
|
|
this.box.addChild(this.argsLine);
|
|
this.box.addChild(this.output);
|
|
this.refresh();
|
|
}
|
|
setArgs(args) {
|
|
this.args = args;
|
|
this.refresh();
|
|
}
|
|
setExpanded(expanded) {
|
|
this.expanded = expanded;
|
|
this.refresh();
|
|
}
|
|
setResult(result, opts) {
|
|
this.result = result;
|
|
this.isPartial = false;
|
|
this.isError = Boolean(opts?.isError);
|
|
this.refresh();
|
|
}
|
|
setPartialResult(result) {
|
|
this.result = result;
|
|
this.isPartial = true;
|
|
this.refresh();
|
|
}
|
|
refresh() {
|
|
const bg = this.isPartial ? theme.toolPendingBg : this.isError ? theme.toolErrorBg : theme.toolSuccessBg;
|
|
this.box.setBgFn((line) => bg(line));
|
|
const display = resolveToolDisplay({
|
|
name: this.toolName,
|
|
args: this.args
|
|
});
|
|
const title = `${display.emoji} ${display.label}${this.isPartial ? " (running)" : ""}`;
|
|
this.header.setText(theme.toolTitle(theme.bold(title)));
|
|
const argLine = formatArgs(this.toolName, this.args);
|
|
this.argsLine.setText(argLine ? theme.dim(argLine) : theme.dim(" "));
|
|
const text = extractText(this.result) || (this.isPartial ? "…" : "");
|
|
if (!this.expanded && text) {
|
|
const lines = text.split("\n");
|
|
const preview = lines.length > PREVIEW_LINES ? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…` : text;
|
|
this.output.setText(preview);
|
|
} else this.output.setText(text);
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/components/user-message.ts
|
|
var UserMessageComponent = class extends Container {
|
|
constructor(text) {
|
|
super();
|
|
this.body = new Markdown(text, 1, 1, markdownTheme, {
|
|
bgColor: (line) => theme.userBg(line),
|
|
color: (line) => theme.userText(line)
|
|
});
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(this.body);
|
|
}
|
|
setText(text) {
|
|
this.body.setText(text);
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/components/chat-log.ts
|
|
var ChatLog = class extends Container {
|
|
constructor(..._args) {
|
|
super(..._args);
|
|
this.toolById = /* @__PURE__ */ new Map();
|
|
this.streamingRuns = /* @__PURE__ */ new Map();
|
|
this.toolsExpanded = false;
|
|
}
|
|
clearAll() {
|
|
this.clear();
|
|
this.toolById.clear();
|
|
this.streamingRuns.clear();
|
|
}
|
|
addSystem(text) {
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.system(text), 1, 0));
|
|
}
|
|
addUser(text) {
|
|
this.addChild(new UserMessageComponent(text));
|
|
}
|
|
resolveRunId(runId) {
|
|
return runId ?? "default";
|
|
}
|
|
startAssistant(text, runId) {
|
|
const component = new AssistantMessageComponent(text);
|
|
this.streamingRuns.set(this.resolveRunId(runId), component);
|
|
this.addChild(component);
|
|
return component;
|
|
}
|
|
updateAssistant(text, runId) {
|
|
const effectiveRunId = this.resolveRunId(runId);
|
|
const existing = this.streamingRuns.get(effectiveRunId);
|
|
if (!existing) {
|
|
this.startAssistant(text, runId);
|
|
return;
|
|
}
|
|
existing.setText(text);
|
|
}
|
|
finalizeAssistant(text, runId) {
|
|
const effectiveRunId = this.resolveRunId(runId);
|
|
const existing = this.streamingRuns.get(effectiveRunId);
|
|
if (existing) {
|
|
existing.setText(text);
|
|
this.streamingRuns.delete(effectiveRunId);
|
|
return;
|
|
}
|
|
this.addChild(new AssistantMessageComponent(text));
|
|
}
|
|
startTool(toolCallId, toolName, args) {
|
|
const existing = this.toolById.get(toolCallId);
|
|
if (existing) {
|
|
existing.setArgs(args);
|
|
return existing;
|
|
}
|
|
const component = new ToolExecutionComponent(toolName, args);
|
|
component.setExpanded(this.toolsExpanded);
|
|
this.toolById.set(toolCallId, component);
|
|
this.addChild(component);
|
|
return component;
|
|
}
|
|
updateToolArgs(toolCallId, args) {
|
|
const existing = this.toolById.get(toolCallId);
|
|
if (!existing) return;
|
|
existing.setArgs(args);
|
|
}
|
|
updateToolResult(toolCallId, result, opts) {
|
|
const existing = this.toolById.get(toolCallId);
|
|
if (!existing) return;
|
|
if (opts?.partial) {
|
|
existing.setPartialResult(result);
|
|
return;
|
|
}
|
|
existing.setResult(result, { isError: opts?.isError });
|
|
}
|
|
setToolsExpanded(expanded) {
|
|
this.toolsExpanded = expanded;
|
|
for (const tool of this.toolById.values()) tool.setExpanded(expanded);
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/components/custom-editor.ts
|
|
var CustomEditor = class extends Editor {
|
|
handleInput(data) {
|
|
if (matchesKey(data, Key.alt("enter")) && this.onAltEnter) {
|
|
this.onAltEnter();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.ctrl("l")) && this.onCtrlL) {
|
|
this.onCtrlL();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.ctrl("o")) && this.onCtrlO) {
|
|
this.onCtrlO();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.ctrl("p")) && this.onCtrlP) {
|
|
this.onCtrlP();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.ctrl("g")) && this.onCtrlG) {
|
|
this.onCtrlG();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.ctrl("t")) && this.onCtrlT) {
|
|
this.onCtrlT();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.shift("tab")) && this.onShiftTab) {
|
|
this.onShiftTab();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.escape) && this.onEscape && !this.isShowingAutocomplete()) {
|
|
this.onEscape();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.ctrl("c")) && this.onCtrlC) {
|
|
this.onCtrlC();
|
|
return;
|
|
}
|
|
if (matchesKey(data, Key.ctrl("d"))) {
|
|
if (this.getText().length === 0 && this.onCtrlD) this.onCtrlD();
|
|
return;
|
|
}
|
|
super.handleInput(data);
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/gateway-chat.ts
|
|
var GatewayChatClient = class {
|
|
constructor(opts) {
|
|
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: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
|
clientDisplayName: "openclaw-tui",
|
|
clientVersion: VERSION,
|
|
platform: process.platform,
|
|
mode: GATEWAY_CLIENT_MODES.UI,
|
|
caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
|
|
instanceId: randomUUID(),
|
|
minProtocol: PROTOCOL_VERSION,
|
|
maxProtocol: PROTOCOL_VERSION,
|
|
onHelloOk: (hello) => {
|
|
this.hello = hello;
|
|
this.resolveReady?.();
|
|
this.onConnected?.();
|
|
},
|
|
onEvent: (evt) => {
|
|
this.onEvent?.({
|
|
event: evt.event,
|
|
payload: evt.payload,
|
|
seq: evt.seq
|
|
});
|
|
},
|
|
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) {
|
|
const runId = opts.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) {
|
|
return await this.client.request("chat.abort", {
|
|
sessionKey: opts.sessionKey,
|
|
runId: opts.runId
|
|
});
|
|
}
|
|
async loadHistory(opts) {
|
|
return await this.client.request("chat.history", {
|
|
sessionKey: opts.sessionKey,
|
|
limit: opts.limit
|
|
});
|
|
}
|
|
async listSessions(opts) {
|
|
return await this.client.request("sessions.list", {
|
|
limit: opts?.limit,
|
|
activeMinutes: opts?.activeMinutes,
|
|
includeGlobal: opts?.includeGlobal,
|
|
includeUnknown: opts?.includeUnknown,
|
|
includeDerivedTitles: opts?.includeDerivedTitles,
|
|
includeLastMessage: opts?.includeLastMessage,
|
|
agentId: opts?.agentId
|
|
});
|
|
}
|
|
async listAgents() {
|
|
return await this.client.request("agents.list", {});
|
|
}
|
|
async patchSession(opts) {
|
|
return await this.client.request("sessions.patch", opts);
|
|
}
|
|
async resetSession(key) {
|
|
return await this.client.request("sessions.reset", { key });
|
|
}
|
|
async getStatus() {
|
|
return await this.client.request("status");
|
|
}
|
|
async listModels() {
|
|
const res = await this.client.request("models.list");
|
|
return Array.isArray(res?.models) ? res.models : [];
|
|
}
|
|
};
|
|
function resolveGatewayConnection(opts) {
|
|
const config = loadConfig();
|
|
const isRemoteMode = config.gateway?.mode === "remote";
|
|
const remote = isRemoteMode ? config.gateway?.remote : void 0;
|
|
const authToken = config.gateway?.auth?.token;
|
|
const localPort = resolveGatewayPort(config);
|
|
const urlOverride = typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : void 0;
|
|
const explicitAuth = resolveExplicitGatewayAuth({
|
|
token: opts.token,
|
|
password: opts.password
|
|
});
|
|
ensureExplicitGatewayAuth({
|
|
urlOverride,
|
|
auth: explicitAuth,
|
|
errorHint: "Fix: pass --token or --password when using --url."
|
|
});
|
|
return {
|
|
url: urlOverride || (typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : void 0) || `ws://127.0.0.1:${localPort}`,
|
|
token: explicitAuth.token || (!urlOverride ? isRemoteMode ? typeof remote?.token === "string" && remote.token.trim().length > 0 ? remote.token.trim() : void 0 : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || (typeof authToken === "string" && authToken.trim().length > 0 ? authToken.trim() : void 0) : void 0),
|
|
password: explicitAuth.password || (!urlOverride ? process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || (typeof remote?.password === "string" && remote.password.trim().length > 0 ? remote.password.trim() : void 0) : void 0)
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/utils/time-format.ts
|
|
function formatRelativeTime(timestamp) {
|
|
const diff = Date.now() - timestamp;
|
|
const seconds = Math.floor(diff / 1e3);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
if (seconds < 60) return "just now";
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
if (days === 1) return "Yesterday";
|
|
if (days < 7) return `${days}d ago`;
|
|
return new Date(timestamp).toLocaleDateString(void 0, {
|
|
month: "short",
|
|
day: "numeric"
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/components/fuzzy-filter.ts
|
|
/**
|
|
* Shared fuzzy filtering utilities for select list components.
|
|
*/
|
|
/**
|
|
* Word boundary characters for matching.
|
|
*/
|
|
const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
|
|
/**
|
|
* Check if position is at a word boundary.
|
|
*/
|
|
function isWordBoundary(text, index) {
|
|
return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
|
|
}
|
|
/**
|
|
* Find index where query matches at a word boundary in text.
|
|
* Returns null if no match.
|
|
*/
|
|
function findWordBoundaryIndex(text, query) {
|
|
if (!query) return null;
|
|
const textLower = text.toLowerCase();
|
|
const queryLower = query.toLowerCase();
|
|
const maxIndex = textLower.length - queryLower.length;
|
|
if (maxIndex < 0) return null;
|
|
for (let i = 0; i <= maxIndex; i++) if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) return i;
|
|
return null;
|
|
}
|
|
/**
|
|
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
|
|
* Returns score (lower = better) or null if no match.
|
|
*/
|
|
function fuzzyMatchLower(queryLower, textLower) {
|
|
if (queryLower.length === 0) return 0;
|
|
if (queryLower.length > textLower.length) return null;
|
|
let queryIndex = 0;
|
|
let score = 0;
|
|
let lastMatchIndex = -1;
|
|
let consecutiveMatches = 0;
|
|
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) if (textLower[i] === queryLower[queryIndex]) {
|
|
const isAtWordBoundary = isWordBoundary(textLower, i);
|
|
if (lastMatchIndex === i - 1) {
|
|
consecutiveMatches++;
|
|
score -= consecutiveMatches * 5;
|
|
} else {
|
|
consecutiveMatches = 0;
|
|
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2;
|
|
}
|
|
if (isAtWordBoundary) score -= 10;
|
|
score += i * .1;
|
|
lastMatchIndex = i;
|
|
queryIndex++;
|
|
}
|
|
return queryIndex < queryLower.length ? null : score;
|
|
}
|
|
/**
|
|
* Filter items using pre-lowercased searchTextLower field.
|
|
* Supports space-separated tokens (all must match).
|
|
*/
|
|
function fuzzyFilterLower(items, queryLower) {
|
|
const trimmed = queryLower.trim();
|
|
if (!trimmed) return items;
|
|
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
|
|
if (tokens.length === 0) return items;
|
|
const results = [];
|
|
for (const item of items) {
|
|
const text = item.searchTextLower ?? "";
|
|
let totalScore = 0;
|
|
let allMatch = true;
|
|
for (const token of tokens) {
|
|
const score = fuzzyMatchLower(token, text);
|
|
if (score !== null) totalScore += score;
|
|
else {
|
|
allMatch = false;
|
|
break;
|
|
}
|
|
}
|
|
if (allMatch) results.push({
|
|
item,
|
|
score: totalScore
|
|
});
|
|
}
|
|
results.sort((a, b) => a.score - b.score);
|
|
return results.map((r) => r.item);
|
|
}
|
|
/**
|
|
* Prepare items for fuzzy filtering by pre-computing lowercase search text.
|
|
*/
|
|
function prepareSearchItems(items) {
|
|
return items.map((item) => {
|
|
const parts = [];
|
|
if (item.label) parts.push(item.label);
|
|
if (item.description) parts.push(item.description);
|
|
if (item.searchText) parts.push(item.searchText);
|
|
return {
|
|
...item,
|
|
searchTextLower: parts.join(" ").toLowerCase()
|
|
};
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/components/filterable-select-list.ts
|
|
/**
|
|
* Combines text input filtering with a select list.
|
|
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
|
|
*/
|
|
var FilterableSelectList = class {
|
|
constructor(items, maxVisible, theme) {
|
|
this.filterText = "";
|
|
this.allItems = prepareSearchItems(items);
|
|
this.maxVisible = maxVisible;
|
|
this.theme = theme;
|
|
this.input = new Input();
|
|
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
|
}
|
|
applyFilter() {
|
|
const queryLower = this.filterText.toLowerCase();
|
|
if (!queryLower.trim()) {
|
|
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
|
return;
|
|
}
|
|
this.selectList = new SelectList(fuzzyFilterLower(this.allItems, queryLower), this.maxVisible, this.theme);
|
|
}
|
|
invalidate() {
|
|
this.input.invalidate();
|
|
this.selectList.invalidate();
|
|
}
|
|
render(width) {
|
|
const lines = [];
|
|
const filterLabel = this.theme.filterLabel("Filter: ");
|
|
const inputText = this.input.render(width - 8)[0] ?? "";
|
|
lines.push(filterLabel + inputText);
|
|
lines.push(chalk.dim("─".repeat(Math.max(0, width))));
|
|
const listLines = this.selectList.render(width);
|
|
lines.push(...listLines);
|
|
return lines;
|
|
}
|
|
handleInput(keyData) {
|
|
const allowVimNav = !this.filterText.trim();
|
|
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || allowVimNav && keyData === "k") {
|
|
this.selectList.handleInput("\x1B[A");
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || allowVimNav && keyData === "j") {
|
|
this.selectList.handleInput("\x1B[B");
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "enter")) {
|
|
const selected = this.selectList.getSelectedItem();
|
|
if (selected) this.onSelect?.(selected);
|
|
return;
|
|
}
|
|
if (getEditorKeybindings().matches(keyData, "selectCancel")) {
|
|
if (this.filterText) {
|
|
this.filterText = "";
|
|
this.input.setValue("");
|
|
this.applyFilter();
|
|
} else this.onCancel?.();
|
|
return;
|
|
}
|
|
const prevValue = this.input.getValue();
|
|
this.input.handleInput(keyData);
|
|
const newValue = this.input.getValue();
|
|
if (newValue !== prevValue) {
|
|
this.filterText = newValue;
|
|
this.applyFilter();
|
|
}
|
|
}
|
|
getSelectedItem() {
|
|
return this.selectList.getSelectedItem();
|
|
}
|
|
getFilterText() {
|
|
return this.filterText;
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/components/searchable-select-list.ts
|
|
/**
|
|
* A select list with a search input at the top for fuzzy filtering.
|
|
*/
|
|
var SearchableSelectList = class {
|
|
constructor(items, maxVisible, theme) {
|
|
this.selectedIndex = 0;
|
|
this.regexCache = /* @__PURE__ */ new Map();
|
|
this.compareByScore = (a, b) => {
|
|
if (a.tier !== b.tier) return a.tier - b.tier;
|
|
if (a.score !== b.score) return a.score - b.score;
|
|
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
|
|
};
|
|
this.items = items;
|
|
this.filteredItems = items;
|
|
this.maxVisible = maxVisible;
|
|
this.theme = theme;
|
|
this.searchInput = new Input();
|
|
}
|
|
getCachedRegex(pattern) {
|
|
let regex = this.regexCache.get(pattern);
|
|
if (!regex) {
|
|
regex = new RegExp(this.escapeRegex(pattern), "gi");
|
|
this.regexCache.set(pattern, regex);
|
|
}
|
|
return regex;
|
|
}
|
|
updateFilter() {
|
|
const query = this.searchInput.getValue().trim();
|
|
if (!query) this.filteredItems = this.items;
|
|
else this.filteredItems = this.smartFilter(query);
|
|
this.selectedIndex = 0;
|
|
this.notifySelectionChange();
|
|
}
|
|
/**
|
|
* Smart filtering that prioritizes:
|
|
* 1. Exact substring match in label (highest priority)
|
|
* 2. Word-boundary prefix match in label
|
|
* 3. Exact substring in description
|
|
* 4. Fuzzy match (lowest priority)
|
|
*/
|
|
smartFilter(query) {
|
|
const q = query.toLowerCase();
|
|
const scoredItems = [];
|
|
const fuzzyCandidates = [];
|
|
for (const item of this.items) {
|
|
const label = item.label.toLowerCase();
|
|
const desc = (item.description ?? "").toLowerCase();
|
|
const labelIndex = label.indexOf(q);
|
|
if (labelIndex !== -1) {
|
|
scoredItems.push({
|
|
item,
|
|
tier: 0,
|
|
score: labelIndex
|
|
});
|
|
continue;
|
|
}
|
|
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
|
|
if (wordBoundaryIndex !== null) {
|
|
scoredItems.push({
|
|
item,
|
|
tier: 1,
|
|
score: wordBoundaryIndex
|
|
});
|
|
continue;
|
|
}
|
|
const descIndex = desc.indexOf(q);
|
|
if (descIndex !== -1) {
|
|
scoredItems.push({
|
|
item,
|
|
tier: 2,
|
|
score: descIndex
|
|
});
|
|
continue;
|
|
}
|
|
fuzzyCandidates.push(item);
|
|
}
|
|
scoredItems.sort(this.compareByScore);
|
|
const fuzzyMatches = fuzzyFilterLower(prepareSearchItems(fuzzyCandidates), q);
|
|
return [...scoredItems.map((s) => s.item), ...fuzzyMatches];
|
|
}
|
|
escapeRegex(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
getItemLabel(item) {
|
|
return item.label || item.value;
|
|
}
|
|
highlightMatch(text, query) {
|
|
const tokens = query.trim().split(/\s+/).map((token) => token.toLowerCase()).filter((token) => token.length > 0);
|
|
if (tokens.length === 0) return text;
|
|
const uniqueTokens = Array.from(new Set(tokens)).toSorted((a, b) => b.length - a.length);
|
|
let result = text;
|
|
for (const token of uniqueTokens) {
|
|
const regex = this.getCachedRegex(token);
|
|
result = result.replace(regex, (match) => this.theme.matchHighlight(match));
|
|
}
|
|
return result;
|
|
}
|
|
setSelectedIndex(index) {
|
|
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
|
}
|
|
invalidate() {
|
|
this.searchInput.invalidate();
|
|
}
|
|
render(width) {
|
|
const lines = [];
|
|
const prompt = this.theme.searchPrompt("search: ");
|
|
const inputWidth = Math.max(1, width - visibleWidth(prompt));
|
|
const inputText = this.searchInput.render(inputWidth)[0] ?? "";
|
|
lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
|
|
lines.push("");
|
|
const query = this.searchInput.getValue().trim();
|
|
if (this.filteredItems.length === 0) {
|
|
lines.push(this.theme.noMatch(" No matches"));
|
|
return lines;
|
|
}
|
|
const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible));
|
|
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const item = this.filteredItems[i];
|
|
if (!item) continue;
|
|
const isSelected = i === this.selectedIndex;
|
|
lines.push(this.renderItemLine(item, isSelected, width, query));
|
|
}
|
|
if (this.filteredItems.length > this.maxVisible) {
|
|
const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
|
|
lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
|
|
}
|
|
return lines;
|
|
}
|
|
renderItemLine(item, isSelected, width, query) {
|
|
const prefix = isSelected ? "→ " : " ";
|
|
const prefixWidth = prefix.length;
|
|
const displayValue = this.getItemLabel(item);
|
|
if (item.description && width > 40) {
|
|
const truncatedValue = truncateToWidth(displayValue, Math.min(30, width - prefixWidth - 4), "");
|
|
const valueText = this.highlightMatch(truncatedValue, query);
|
|
const spacingWidth = Math.max(1, 32 - visibleWidth(valueText));
|
|
const spacing = " ".repeat(spacingWidth);
|
|
const remainingWidth = width - (prefixWidth + visibleWidth(valueText) + spacing.length) - 2;
|
|
if (remainingWidth > 10) {
|
|
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
|
|
const highlightedDesc = this.highlightMatch(truncatedDesc, query);
|
|
const line = `${prefix}${valueText}${spacing}${isSelected ? highlightedDesc : this.theme.description(highlightedDesc)}`;
|
|
return isSelected ? this.theme.selectedText(line) : line;
|
|
}
|
|
}
|
|
const truncatedValue = truncateToWidth(displayValue, width - prefixWidth - 2, "");
|
|
const line = `${prefix}${this.highlightMatch(truncatedValue, query)}`;
|
|
return isSelected ? this.theme.selectedText(line) : line;
|
|
}
|
|
handleInput(keyData) {
|
|
if (isKeyRelease(keyData)) return;
|
|
const allowVimNav = !this.searchInput.getValue().trim();
|
|
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || allowVimNav && keyData === "k") {
|
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
this.notifySelectionChange();
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || allowVimNav && keyData === "j") {
|
|
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
|
this.notifySelectionChange();
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "enter")) {
|
|
const item = this.filteredItems[this.selectedIndex];
|
|
if (item && this.onSelect) this.onSelect(item);
|
|
return;
|
|
}
|
|
if (getEditorKeybindings().matches(keyData, "selectCancel")) {
|
|
if (this.onCancel) this.onCancel();
|
|
return;
|
|
}
|
|
const prevValue = this.searchInput.getValue();
|
|
this.searchInput.handleInput(keyData);
|
|
if (prevValue !== this.searchInput.getValue()) this.updateFilter();
|
|
}
|
|
notifySelectionChange() {
|
|
const item = this.filteredItems[this.selectedIndex];
|
|
if (item && this.onSelectionChange) this.onSelectionChange(item);
|
|
}
|
|
getSelectedItem() {
|
|
return this.filteredItems[this.selectedIndex] ?? null;
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/components/selectors.ts
|
|
function createSearchableSelectList(items, maxVisible = 7) {
|
|
return new SearchableSelectList(items, maxVisible, searchableSelectListTheme);
|
|
}
|
|
function createFilterableSelectList(items, maxVisible = 7) {
|
|
return new FilterableSelectList(items, maxVisible, filterableSelectListTheme);
|
|
}
|
|
function createSettingsList(items, onChange, onCancel, maxVisible = 7) {
|
|
return new SettingsList(items, maxVisible, settingsListTheme, onChange, onCancel);
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-status-summary.ts
|
|
function formatStatusSummary(summary) {
|
|
const lines = [];
|
|
lines.push("Gateway status");
|
|
if (!summary.linkChannel) lines.push("Link channel: unknown");
|
|
else {
|
|
const linkLabel = summary.linkChannel.label ?? "Link channel";
|
|
const linked = summary.linkChannel.linked === true;
|
|
const authAge = linked && typeof summary.linkChannel.authAgeMs === "number" ? ` (last refreshed ${formatAge(summary.linkChannel.authAgeMs)})` : "";
|
|
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
|
|
}
|
|
const providerSummary = Array.isArray(summary.providerSummary) ? summary.providerSummary : [];
|
|
if (providerSummary.length > 0) {
|
|
lines.push("");
|
|
lines.push("System:");
|
|
for (const line of providerSummary) lines.push(` ${line}`);
|
|
}
|
|
const heartbeatAgents = summary.heartbeat?.agents ?? [];
|
|
if (heartbeatAgents.length > 0) {
|
|
const heartbeatParts = heartbeatAgents.map((agent) => {
|
|
const agentId = agent.agentId ?? "unknown";
|
|
if (!agent.enabled || !agent.everyMs) return `disabled (${agentId})`;
|
|
return `${agent.every ?? "unknown"} (${agentId})`;
|
|
});
|
|
lines.push("");
|
|
lines.push(`Heartbeat: ${heartbeatParts.join(", ")}`);
|
|
}
|
|
const sessionPaths = summary.sessions?.paths ?? [];
|
|
if (sessionPaths.length === 1) lines.push(`Session store: ${sessionPaths[0]}`);
|
|
else if (sessionPaths.length > 1) lines.push(`Session stores: ${sessionPaths.length}`);
|
|
const defaults = summary.sessions?.defaults;
|
|
const defaultModel = defaults?.model ?? "unknown";
|
|
const defaultCtx = typeof defaults?.contextTokens === "number" ? ` (${formatTokenCount(defaults.contextTokens)} ctx)` : "";
|
|
lines.push(`Default model: ${defaultModel}${defaultCtx}`);
|
|
const sessionCount = summary.sessions?.count ?? 0;
|
|
lines.push(`Active sessions: ${sessionCount}`);
|
|
const recent = Array.isArray(summary.sessions?.recent) ? summary.sessions?.recent : [];
|
|
if (recent.length > 0) {
|
|
lines.push("Recent sessions:");
|
|
for (const entry of recent) {
|
|
const ageLabel = typeof entry.age === "number" ? formatAge(entry.age) : "no activity";
|
|
const model = entry.model ?? "unknown";
|
|
const usage = formatContextUsageLine({
|
|
total: entry.totalTokens ?? null,
|
|
context: entry.contextTokens ?? null,
|
|
remaining: entry.remainingTokens ?? null,
|
|
percent: entry.percentUsed ?? null
|
|
});
|
|
const flags = entry.flags?.length ? ` | flags: ${entry.flags.join(", ")}` : "";
|
|
lines.push(`- ${entry.key}${entry.kind ? ` [${entry.kind}]` : ""} | ${ageLabel} | model ${model} | ${usage}${flags}`);
|
|
}
|
|
}
|
|
const queued = Array.isArray(summary.queuedSystemEvents) ? summary.queuedSystemEvents : [];
|
|
if (queued.length > 0) {
|
|
const preview = queued.slice(0, 3).join(" | ");
|
|
lines.push(`Queued system events (${queued.length}): ${preview}`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-command-handlers.ts
|
|
function createCommandHandlers(context) {
|
|
const { client, chatLog, tui, opts, state, deliverDefault, openOverlay, closeOverlay, refreshSessionInfo, loadHistory, setSession, refreshAgents, abortActive, setActivityStatus, formatSessionKey, applySessionInfoFromPatch, noteLocalRunId, forgetLocalRunId } = context;
|
|
const setAgent = async (id) => {
|
|
state.currentAgentId = normalizeAgentId(id);
|
|
await setSession("");
|
|
};
|
|
const openModelSelector = async () => {
|
|
try {
|
|
const models = await client.listModels();
|
|
if (models.length === 0) {
|
|
chatLog.addSystem("no models available");
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
const selector = createSearchableSelectList(models.map((model) => ({
|
|
value: `${model.provider}/${model.id}`,
|
|
label: `${model.provider}/${model.id}`,
|
|
description: model.name && model.name !== model.id ? model.name : ""
|
|
})), 9);
|
|
selector.onSelect = (item) => {
|
|
(async () => {
|
|
try {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
model: item.value
|
|
});
|
|
chatLog.addSystem(`model set to ${item.value}`);
|
|
applySessionInfoFromPatch(result);
|
|
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 openAgentSelector = async () => {
|
|
await refreshAgents();
|
|
if (state.agents.length === 0) {
|
|
chatLog.addSystem("no agents found");
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
const selector = createSearchableSelectList(state.agents.map((agent) => ({
|
|
value: agent.id,
|
|
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
|
description: agent.id === state.agentDefaultId ? "default" : ""
|
|
})), 9);
|
|
selector.onSelect = (item) => {
|
|
(async () => {
|
|
closeOverlay();
|
|
await setAgent(item.value);
|
|
tui.requestRender();
|
|
})();
|
|
};
|
|
selector.onCancel = () => {
|
|
closeOverlay();
|
|
tui.requestRender();
|
|
};
|
|
openOverlay(selector);
|
|
tui.requestRender();
|
|
};
|
|
const openSessionSelector = async () => {
|
|
try {
|
|
const selector = createFilterableSelectList((await client.listSessions({
|
|
includeGlobal: false,
|
|
includeUnknown: false,
|
|
includeDerivedTitles: true,
|
|
includeLastMessage: true,
|
|
agentId: state.currentAgentId
|
|
})).sessions.map((session) => {
|
|
const title = session.derivedTitle ?? session.displayName;
|
|
const formattedKey = formatSessionKey(session.key);
|
|
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
|
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
|
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
|
const description = timePart && preview ? `${timePart} · ${preview}` : preview ?? timePart;
|
|
return {
|
|
value: session.key,
|
|
label,
|
|
description,
|
|
searchText: [
|
|
session.displayName,
|
|
session.label,
|
|
session.subject,
|
|
session.sessionId,
|
|
session.key,
|
|
session.lastMessagePreview
|
|
].filter(Boolean).join(" ")
|
|
};
|
|
}), 9);
|
|
selector.onSelect = (item) => {
|
|
(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 = () => {
|
|
openOverlay(createSettingsList([{
|
|
id: "tools",
|
|
label: "Tool output",
|
|
currentValue: state.toolsExpanded ? "expanded" : "collapsed",
|
|
values: ["collapsed", "expanded"]
|
|
}, {
|
|
id: "thinking",
|
|
label: "Show thinking",
|
|
currentValue: state.showThinking ? "on" : "off",
|
|
values: ["off", "on"]
|
|
}], (id, value) => {
|
|
if (id === "tools") {
|
|
state.toolsExpanded = value === "expanded";
|
|
chatLog.setToolsExpanded(state.toolsExpanded);
|
|
}
|
|
if (id === "thinking") {
|
|
state.showThinking = value === "on";
|
|
loadHistory();
|
|
}
|
|
tui.requestRender();
|
|
}, () => {
|
|
closeOverlay();
|
|
tui.requestRender();
|
|
}));
|
|
tui.requestRender();
|
|
};
|
|
const handleCommand = async (raw) => {
|
|
const { name, args } = parseCommand(raw);
|
|
if (!name) return;
|
|
switch (name) {
|
|
case "help":
|
|
chatLog.addSystem(helpText({
|
|
provider: state.sessionInfo.modelProvider,
|
|
model: state.sessionInfo.model
|
|
}));
|
|
break;
|
|
case "status":
|
|
try {
|
|
const status = await client.getStatus();
|
|
if (typeof status === "string") {
|
|
chatLog.addSystem(status);
|
|
break;
|
|
}
|
|
if (status && typeof status === "object") {
|
|
const lines = formatStatusSummary(status);
|
|
for (const line of lines) chatLog.addSystem(line);
|
|
break;
|
|
}
|
|
chatLog.addSystem("status: unknown response");
|
|
} catch (err) {
|
|
chatLog.addSystem(`status failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
case "agent":
|
|
if (!args) await openAgentSelector();
|
|
else await setAgent(args);
|
|
break;
|
|
case "agents":
|
|
await openAgentSelector();
|
|
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 {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
model: args
|
|
});
|
|
chatLog.addSystem(`model set to ${args}`);
|
|
applySessionInfoFromPatch(result);
|
|
await refreshSessionInfo();
|
|
} catch (err) {
|
|
chatLog.addSystem(`model set failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
case "models":
|
|
await openModelSelector();
|
|
break;
|
|
case "think":
|
|
if (!args) {
|
|
const levels = formatThinkingLevels(state.sessionInfo.modelProvider, state.sessionInfo.model, "|");
|
|
chatLog.addSystem(`usage: /think <${levels}>`);
|
|
break;
|
|
}
|
|
try {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
thinkingLevel: args
|
|
});
|
|
chatLog.addSystem(`thinking set to ${args}`);
|
|
applySessionInfoFromPatch(result);
|
|
await refreshSessionInfo();
|
|
} catch (err) {
|
|
chatLog.addSystem(`think failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
case "verbose":
|
|
if (!args) {
|
|
chatLog.addSystem("usage: /verbose <on|off>");
|
|
break;
|
|
}
|
|
try {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
verboseLevel: args
|
|
});
|
|
chatLog.addSystem(`verbose set to ${args}`);
|
|
applySessionInfoFromPatch(result);
|
|
await loadHistory();
|
|
} catch (err) {
|
|
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
case "reasoning":
|
|
if (!args) {
|
|
chatLog.addSystem("usage: /reasoning <on|off>");
|
|
break;
|
|
}
|
|
try {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
reasoningLevel: args
|
|
});
|
|
chatLog.addSystem(`reasoning set to ${args}`);
|
|
applySessionInfoFromPatch(result);
|
|
await refreshSessionInfo();
|
|
} catch (err) {
|
|
chatLog.addSystem(`reasoning failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
case "usage": {
|
|
const normalized = args ? normalizeUsageDisplay(args) : void 0;
|
|
if (args && !normalized) {
|
|
chatLog.addSystem("usage: /usage <off|tokens|full>");
|
|
break;
|
|
}
|
|
const currentRaw = state.sessionInfo.responseUsage;
|
|
const current = resolveResponseUsageMode(currentRaw);
|
|
const next = normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
|
|
try {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
responseUsage: next === "off" ? null : next
|
|
});
|
|
chatLog.addSystem(`usage footer: ${next}`);
|
|
applySessionInfoFromPatch(result);
|
|
await refreshSessionInfo();
|
|
} catch (err) {
|
|
chatLog.addSystem(`usage failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
}
|
|
case "elevated":
|
|
if (!args) {
|
|
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
|
|
break;
|
|
}
|
|
if (![
|
|
"on",
|
|
"off",
|
|
"ask",
|
|
"full"
|
|
].includes(args)) {
|
|
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
|
|
break;
|
|
}
|
|
try {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
elevatedLevel: args
|
|
});
|
|
chatLog.addSystem(`elevated set to ${args}`);
|
|
applySessionInfoFromPatch(result);
|
|
await refreshSessionInfo();
|
|
} catch (err) {
|
|
chatLog.addSystem(`elevated failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
case "activation":
|
|
if (!args) {
|
|
chatLog.addSystem("usage: /activation <mention|always>");
|
|
break;
|
|
}
|
|
try {
|
|
const result = await client.patchSession({
|
|
key: state.currentSessionKey,
|
|
groupActivation: args === "always" ? "always" : "mention"
|
|
});
|
|
chatLog.addSystem(`activation set to ${args}`);
|
|
applySessionInfoFromPatch(result);
|
|
await refreshSessionInfo();
|
|
} catch (err) {
|
|
chatLog.addSystem(`activation failed: ${String(err)}`);
|
|
}
|
|
break;
|
|
case "new":
|
|
case "reset":
|
|
try {
|
|
state.sessionInfo.inputTokens = null;
|
|
state.sessionInfo.outputTokens = null;
|
|
state.sessionInfo.totalTokens = null;
|
|
tui.requestRender();
|
|
await client.resetSession(state.currentSessionKey);
|
|
chatLog.addSystem(`session ${state.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();
|
|
tui.stop();
|
|
process.exit(0);
|
|
break;
|
|
default:
|
|
await sendMessage(raw);
|
|
break;
|
|
}
|
|
tui.requestRender();
|
|
};
|
|
const sendMessage = async (text) => {
|
|
try {
|
|
chatLog.addUser(text);
|
|
tui.requestRender();
|
|
const runId = randomUUID();
|
|
noteLocalRunId(runId);
|
|
state.activeChatRunId = runId;
|
|
setActivityStatus("sending");
|
|
await client.sendChat({
|
|
sessionKey: state.currentSessionKey,
|
|
message: text,
|
|
thinking: opts.thinking,
|
|
deliver: deliverDefault,
|
|
timeoutMs: opts.timeoutMs,
|
|
runId
|
|
});
|
|
setActivityStatus("waiting");
|
|
} catch (err) {
|
|
if (state.activeChatRunId) forgetLocalRunId?.(state.activeChatRunId);
|
|
state.activeChatRunId = null;
|
|
chatLog.addSystem(`send failed: ${String(err)}`);
|
|
setActivityStatus("error");
|
|
}
|
|
tui.requestRender();
|
|
};
|
|
return {
|
|
handleCommand,
|
|
sendMessage,
|
|
openModelSelector,
|
|
openAgentSelector,
|
|
openSessionSelector,
|
|
openSettings,
|
|
setAgent
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-stream-assembler.ts
|
|
var TuiStreamAssembler = class {
|
|
constructor() {
|
|
this.runs = /* @__PURE__ */ new Map();
|
|
}
|
|
getOrCreateRun(runId) {
|
|
let state = this.runs.get(runId);
|
|
if (!state) {
|
|
state = {
|
|
thinkingText: "",
|
|
contentText: "",
|
|
displayText: ""
|
|
};
|
|
this.runs.set(runId, state);
|
|
}
|
|
return state;
|
|
}
|
|
updateRunState(state, message, showThinking) {
|
|
const thinkingText = extractThinkingFromMessage(message);
|
|
const contentText = extractContentFromMessage(message);
|
|
if (thinkingText) state.thinkingText = thinkingText;
|
|
if (contentText) state.contentText = contentText;
|
|
state.displayText = composeThinkingAndContent({
|
|
thinkingText: state.thinkingText,
|
|
contentText: state.contentText,
|
|
showThinking
|
|
});
|
|
}
|
|
ingestDelta(runId, message, showThinking) {
|
|
const state = this.getOrCreateRun(runId);
|
|
const previousDisplayText = state.displayText;
|
|
this.updateRunState(state, message, showThinking);
|
|
if (!state.displayText || state.displayText === previousDisplayText) return null;
|
|
return state.displayText;
|
|
}
|
|
finalize(runId, message, showThinking) {
|
|
const state = this.getOrCreateRun(runId);
|
|
this.updateRunState(state, message, showThinking);
|
|
const finalComposed = state.displayText;
|
|
const finalText = resolveFinalAssistantText({
|
|
finalText: finalComposed,
|
|
streamedText: state.displayText
|
|
});
|
|
this.runs.delete(runId);
|
|
return finalText;
|
|
}
|
|
drop(runId) {
|
|
this.runs.delete(runId);
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-event-handlers.ts
|
|
function createEventHandlers(context) {
|
|
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo, loadHistory, isLocalRunId, forgetLocalRunId, clearLocalRunIds } = context;
|
|
const finalizedRuns = /* @__PURE__ */ new Map();
|
|
const sessionRuns = /* @__PURE__ */ new Map();
|
|
let streamAssembler = new TuiStreamAssembler();
|
|
let lastSessionKey = state.currentSessionKey;
|
|
const pruneRunMap = (runs) => {
|
|
if (runs.size <= 200) return;
|
|
const keepUntil = Date.now() - 600 * 1e3;
|
|
for (const [key, ts] of runs) {
|
|
if (runs.size <= 150) break;
|
|
if (ts < keepUntil) runs.delete(key);
|
|
}
|
|
if (runs.size > 200) for (const key of runs.keys()) {
|
|
runs.delete(key);
|
|
if (runs.size <= 150) break;
|
|
}
|
|
};
|
|
const syncSessionKey = () => {
|
|
if (state.currentSessionKey === lastSessionKey) return;
|
|
lastSessionKey = state.currentSessionKey;
|
|
finalizedRuns.clear();
|
|
sessionRuns.clear();
|
|
streamAssembler = new TuiStreamAssembler();
|
|
clearLocalRunIds?.();
|
|
};
|
|
const noteSessionRun = (runId) => {
|
|
sessionRuns.set(runId, Date.now());
|
|
pruneRunMap(sessionRuns);
|
|
};
|
|
const noteFinalizedRun = (runId) => {
|
|
finalizedRuns.set(runId, Date.now());
|
|
sessionRuns.delete(runId);
|
|
streamAssembler.drop(runId);
|
|
pruneRunMap(finalizedRuns);
|
|
};
|
|
const handleChatEvent = (payload) => {
|
|
if (!payload || typeof payload !== "object") return;
|
|
const evt = payload;
|
|
syncSessionKey();
|
|
if (evt.sessionKey !== state.currentSessionKey) return;
|
|
if (finalizedRuns.has(evt.runId)) {
|
|
if (evt.state === "delta") return;
|
|
if (evt.state === "final") return;
|
|
}
|
|
noteSessionRun(evt.runId);
|
|
if (!state.activeChatRunId) state.activeChatRunId = evt.runId;
|
|
if (evt.state === "delta") {
|
|
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);
|
|
if (!displayText) return;
|
|
chatLog.updateAssistant(displayText, evt.runId);
|
|
setActivityStatus("streaming");
|
|
}
|
|
if (evt.state === "final") {
|
|
if (isCommandMessage(evt.message)) {
|
|
if (isLocalRunId?.(evt.runId)) forgetLocalRunId?.(evt.runId);
|
|
else loadHistory?.();
|
|
const text = extractTextFromMessage(evt.message);
|
|
if (text) chatLog.addSystem(text);
|
|
streamAssembler.drop(evt.runId);
|
|
noteFinalizedRun(evt.runId);
|
|
state.activeChatRunId = null;
|
|
setActivityStatus("idle");
|
|
refreshSessionInfo?.();
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
if (isLocalRunId?.(evt.runId)) forgetLocalRunId?.(evt.runId);
|
|
else loadHistory?.();
|
|
const stopReason = evt.message && typeof evt.message === "object" && !Array.isArray(evt.message) ? typeof evt.message.stopReason === "string" ? evt.message.stopReason : "" : "";
|
|
const finalText = streamAssembler.finalize(evt.runId, evt.message, state.showThinking);
|
|
chatLog.finalizeAssistant(finalText, evt.runId);
|
|
noteFinalizedRun(evt.runId);
|
|
state.activeChatRunId = null;
|
|
setActivityStatus(stopReason === "error" ? "error" : "idle");
|
|
refreshSessionInfo?.();
|
|
}
|
|
if (evt.state === "aborted") {
|
|
chatLog.addSystem("run aborted");
|
|
streamAssembler.drop(evt.runId);
|
|
sessionRuns.delete(evt.runId);
|
|
state.activeChatRunId = null;
|
|
setActivityStatus("aborted");
|
|
refreshSessionInfo?.();
|
|
if (isLocalRunId?.(evt.runId)) forgetLocalRunId?.(evt.runId);
|
|
else loadHistory?.();
|
|
}
|
|
if (evt.state === "error") {
|
|
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
|
streamAssembler.drop(evt.runId);
|
|
sessionRuns.delete(evt.runId);
|
|
state.activeChatRunId = null;
|
|
setActivityStatus("error");
|
|
refreshSessionInfo?.();
|
|
if (isLocalRunId?.(evt.runId)) forgetLocalRunId?.(evt.runId);
|
|
else loadHistory?.();
|
|
}
|
|
tui.requestRender();
|
|
};
|
|
const handleAgentEvent = (payload) => {
|
|
if (!payload || typeof payload !== "object") return;
|
|
const evt = payload;
|
|
syncSessionKey();
|
|
const isActiveRun = evt.runId === state.activeChatRunId;
|
|
if (!(isActiveRun || sessionRuns.has(evt.runId) || finalizedRuns.has(evt.runId))) return;
|
|
if (evt.stream === "tool") {
|
|
const verbose = state.sessionInfo.verboseLevel ?? "off";
|
|
const allowToolEvents = verbose !== "off";
|
|
const allowToolOutput = verbose === "full";
|
|
if (!allowToolEvents) return;
|
|
const data = evt.data ?? {};
|
|
const phase = asString(data.phase, "");
|
|
const toolCallId = asString(data.toolCallId, "");
|
|
const toolName = asString(data.name, "tool");
|
|
if (!toolCallId) return;
|
|
if (phase === "start") chatLog.startTool(toolCallId, toolName, data.args);
|
|
else if (phase === "update") {
|
|
if (!allowToolOutput) return;
|
|
chatLog.updateToolResult(toolCallId, data.partialResult, { partial: true });
|
|
} else if (phase === "result") if (allowToolOutput) chatLog.updateToolResult(toolCallId, data.result, { isError: Boolean(data.isError) });
|
|
else chatLog.updateToolResult(toolCallId, { content: [] }, { isError: Boolean(data.isError) });
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
if (evt.stream === "lifecycle") {
|
|
if (!isActiveRun) return;
|
|
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
|
|
if (phase === "start") setActivityStatus("running");
|
|
if (phase === "end") setActivityStatus("idle");
|
|
if (phase === "error") setActivityStatus("error");
|
|
tui.requestRender();
|
|
}
|
|
};
|
|
return {
|
|
handleChatEvent,
|
|
handleAgentEvent
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-local-shell.ts
|
|
function createLocalShellRunner(deps) {
|
|
let localExecAsked = false;
|
|
let localExecAllowed = false;
|
|
const createSelector = deps.createSelector ?? createSearchableSelectList;
|
|
const spawnCommand = deps.spawnCommand ?? spawn;
|
|
const getCwd = deps.getCwd ?? (() => process.cwd());
|
|
const env = deps.env ?? process.env;
|
|
const maxChars = deps.maxOutputChars ?? 4e4;
|
|
const ensureLocalExecAllowed = async () => {
|
|
if (localExecAllowed) return true;
|
|
if (localExecAsked) return false;
|
|
localExecAsked = true;
|
|
return await new Promise((resolve) => {
|
|
deps.chatLog.addSystem("Allow local shell commands for this session?");
|
|
deps.chatLog.addSystem("This runs commands on YOUR machine (not the gateway) and may delete files or reveal secrets.");
|
|
deps.chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel.");
|
|
const selector = createSelector([{
|
|
value: "no",
|
|
label: "No"
|
|
}, {
|
|
value: "yes",
|
|
label: "Yes"
|
|
}], 2);
|
|
selector.onSelect = (item) => {
|
|
deps.closeOverlay();
|
|
if (item.value === "yes") {
|
|
localExecAllowed = true;
|
|
deps.chatLog.addSystem("local shell: enabled for this session");
|
|
resolve(true);
|
|
} else {
|
|
deps.chatLog.addSystem("local shell: not enabled");
|
|
resolve(false);
|
|
}
|
|
deps.tui.requestRender();
|
|
};
|
|
selector.onCancel = () => {
|
|
deps.closeOverlay();
|
|
deps.chatLog.addSystem("local shell: cancelled");
|
|
deps.tui.requestRender();
|
|
resolve(false);
|
|
};
|
|
deps.openOverlay(selector);
|
|
deps.tui.requestRender();
|
|
});
|
|
};
|
|
const runLocalShellLine = async (line) => {
|
|
const cmd = line.slice(1);
|
|
if (cmd === "") return;
|
|
if (localExecAsked && !localExecAllowed) {
|
|
deps.chatLog.addSystem("local shell: not enabled for this session");
|
|
deps.tui.requestRender();
|
|
return;
|
|
}
|
|
if (!await ensureLocalExecAllowed()) return;
|
|
deps.chatLog.addSystem(`[local] $ ${cmd}`);
|
|
deps.tui.requestRender();
|
|
await new Promise((resolve) => {
|
|
const child = spawnCommand(cmd, {
|
|
shell: true,
|
|
cwd: getCwd(),
|
|
env
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (buf) => {
|
|
stdout += buf.toString("utf8");
|
|
});
|
|
child.stderr.on("data", (buf) => {
|
|
stderr += buf.toString("utf8");
|
|
});
|
|
child.on("close", (code, signal) => {
|
|
const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : "")).slice(0, maxChars).trimEnd();
|
|
if (combined) for (const line of combined.split("\n")) deps.chatLog.addSystem(`[local] ${line}`);
|
|
deps.chatLog.addSystem(`[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`);
|
|
deps.tui.requestRender();
|
|
resolve();
|
|
});
|
|
child.on("error", (err) => {
|
|
deps.chatLog.addSystem(`[local] error: ${String(err)}`);
|
|
deps.tui.requestRender();
|
|
resolve();
|
|
});
|
|
});
|
|
};
|
|
return { runLocalShellLine };
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-overlays.ts
|
|
function createOverlayHandlers(host, fallbackFocus) {
|
|
const openOverlay = (component) => {
|
|
host.showOverlay(component);
|
|
};
|
|
const closeOverlay = () => {
|
|
if (host.hasOverlay()) {
|
|
host.hideOverlay();
|
|
return;
|
|
}
|
|
host.setFocus(fallbackFocus);
|
|
};
|
|
return {
|
|
openOverlay,
|
|
closeOverlay
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-session-actions.ts
|
|
function createSessionActions(context) {
|
|
const { client, chatLog, tui, opts, state, agentNames, initialSessionInput, initialSessionAgentId, resolveSessionKey, updateHeader, updateFooter, updateAutocompleteProvider, setActivityStatus, clearLocalRunIds } = context;
|
|
let refreshSessionInfoPromise = Promise.resolve();
|
|
let lastSessionDefaults = null;
|
|
const applyAgentsResult = (result) => {
|
|
state.agentDefaultId = normalizeAgentId(result.defaultId);
|
|
state.sessionMainKey = normalizeMainKey(result.mainKey);
|
|
state.sessionScope = result.scope ?? state.sessionScope;
|
|
state.agents = result.agents.map((agent) => ({
|
|
id: normalizeAgentId(agent.id),
|
|
name: agent.name?.trim() || void 0
|
|
}));
|
|
agentNames.clear();
|
|
for (const agent of state.agents) if (agent.name) agentNames.set(agent.id, agent.name);
|
|
if (!state.initialSessionApplied) {
|
|
if (initialSessionAgentId) {
|
|
if (state.agents.some((agent) => agent.id === initialSessionAgentId)) state.currentAgentId = initialSessionAgentId;
|
|
} else if (!state.agents.some((agent) => agent.id === state.currentAgentId)) state.currentAgentId = state.agents[0]?.id ?? normalizeAgentId(result.defaultId ?? state.currentAgentId);
|
|
const nextSessionKey = resolveSessionKey(initialSessionInput);
|
|
if (nextSessionKey !== state.currentSessionKey) state.currentSessionKey = nextSessionKey;
|
|
state.initialSessionApplied = true;
|
|
} else if (!state.agents.some((agent) => agent.id === state.currentAgentId)) state.currentAgentId = state.agents[0]?.id ?? normalizeAgentId(result.defaultId ?? state.currentAgentId);
|
|
updateHeader();
|
|
updateFooter();
|
|
};
|
|
const refreshAgents = async () => {
|
|
try {
|
|
applyAgentsResult(await client.listAgents());
|
|
} catch (err) {
|
|
chatLog.addSystem(`agents list failed: ${String(err)}`);
|
|
}
|
|
};
|
|
const updateAgentFromSessionKey = (key) => {
|
|
const parsed = parseAgentSessionKey(key);
|
|
if (!parsed) return;
|
|
const next = normalizeAgentId(parsed.agentId);
|
|
if (next !== state.currentAgentId) state.currentAgentId = next;
|
|
};
|
|
const resolveModelSelection = (entry) => {
|
|
if (entry?.modelProvider || entry?.model) return {
|
|
modelProvider: entry.modelProvider ?? state.sessionInfo.modelProvider,
|
|
model: entry.model ?? state.sessionInfo.model
|
|
};
|
|
const overrideModel = entry?.modelOverride?.trim();
|
|
if (overrideModel) return {
|
|
modelProvider: entry?.providerOverride?.trim() || state.sessionInfo.modelProvider,
|
|
model: overrideModel
|
|
};
|
|
return {
|
|
modelProvider: state.sessionInfo.modelProvider,
|
|
model: state.sessionInfo.model
|
|
};
|
|
};
|
|
const applySessionInfo = (params) => {
|
|
const entry = params.entry ?? void 0;
|
|
const defaults = params.defaults ?? lastSessionDefaults ?? void 0;
|
|
const previousDefaults = lastSessionDefaults;
|
|
const defaultsChanged = params.defaults ? previousDefaults?.model !== params.defaults.model || previousDefaults?.modelProvider !== params.defaults.modelProvider || previousDefaults?.contextTokens !== params.defaults.contextTokens : false;
|
|
if (params.defaults) lastSessionDefaults = params.defaults;
|
|
const entryUpdatedAt = entry?.updatedAt ?? null;
|
|
const currentUpdatedAt = state.sessionInfo.updatedAt ?? null;
|
|
const modelChanged = entry?.modelProvider !== void 0 && entry.modelProvider !== state.sessionInfo.modelProvider || entry?.model !== void 0 && entry.model !== state.sessionInfo.model;
|
|
if (!params.force && entryUpdatedAt !== null && currentUpdatedAt !== null && entryUpdatedAt < currentUpdatedAt && !defaultsChanged && !modelChanged) return;
|
|
const next = { ...state.sessionInfo };
|
|
if (entry?.thinkingLevel !== void 0) next.thinkingLevel = entry.thinkingLevel;
|
|
if (entry?.verboseLevel !== void 0) next.verboseLevel = entry.verboseLevel;
|
|
if (entry?.reasoningLevel !== void 0) next.reasoningLevel = entry.reasoningLevel;
|
|
if (entry?.responseUsage !== void 0) next.responseUsage = entry.responseUsage;
|
|
if (entry?.inputTokens !== void 0) next.inputTokens = entry.inputTokens;
|
|
if (entry?.outputTokens !== void 0) next.outputTokens = entry.outputTokens;
|
|
if (entry?.totalTokens !== void 0) next.totalTokens = entry.totalTokens;
|
|
if (entry?.contextTokens !== void 0 || defaults?.contextTokens !== void 0) next.contextTokens = entry?.contextTokens ?? defaults?.contextTokens ?? state.sessionInfo.contextTokens;
|
|
if (entry?.displayName !== void 0) next.displayName = entry.displayName;
|
|
if (entry?.updatedAt !== void 0) next.updatedAt = entry.updatedAt;
|
|
const selection = resolveModelSelection(entry);
|
|
if (selection.modelProvider !== void 0) next.modelProvider = selection.modelProvider;
|
|
if (selection.model !== void 0) next.model = selection.model;
|
|
state.sessionInfo = next;
|
|
updateAutocompleteProvider();
|
|
updateFooter();
|
|
tui.requestRender();
|
|
};
|
|
const runRefreshSessionInfo = async () => {
|
|
try {
|
|
const resolveListAgentId = () => {
|
|
if (state.currentSessionKey === "global" || state.currentSessionKey === "unknown") return;
|
|
const parsed = parseAgentSessionKey(state.currentSessionKey);
|
|
return parsed?.agentId ? normalizeAgentId(parsed.agentId) : state.currentAgentId;
|
|
};
|
|
const listAgentId = resolveListAgentId();
|
|
const result = await client.listSessions({
|
|
includeGlobal: false,
|
|
includeUnknown: false,
|
|
agentId: listAgentId
|
|
});
|
|
const normalizeMatchKey = (key) => parseAgentSessionKey(key)?.rest ?? key;
|
|
const currentMatchKey = normalizeMatchKey(state.currentSessionKey);
|
|
const entry = result.sessions.find((row) => {
|
|
if (row.key === state.currentSessionKey) return true;
|
|
return normalizeMatchKey(row.key) === currentMatchKey;
|
|
});
|
|
if (entry?.key && entry.key !== state.currentSessionKey) {
|
|
updateAgentFromSessionKey(entry.key);
|
|
state.currentSessionKey = entry.key;
|
|
updateHeader();
|
|
}
|
|
applySessionInfo({
|
|
entry,
|
|
defaults: result.defaults
|
|
});
|
|
} catch (err) {
|
|
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
|
}
|
|
};
|
|
const refreshSessionInfo = async () => {
|
|
refreshSessionInfoPromise = refreshSessionInfoPromise.then(runRefreshSessionInfo, runRefreshSessionInfo);
|
|
await refreshSessionInfoPromise;
|
|
};
|
|
const applySessionInfoFromPatch = (result) => {
|
|
if (!result?.entry) return;
|
|
if (result.key && result.key !== state.currentSessionKey) {
|
|
updateAgentFromSessionKey(result.key);
|
|
state.currentSessionKey = result.key;
|
|
updateHeader();
|
|
}
|
|
const resolved = result.resolved;
|
|
applySessionInfo({
|
|
entry: resolved && (resolved.modelProvider || resolved.model) ? {
|
|
...result.entry,
|
|
modelProvider: resolved.modelProvider ?? result.entry.modelProvider,
|
|
model: resolved.model ?? result.entry.model
|
|
} : result.entry,
|
|
force: true
|
|
});
|
|
};
|
|
const loadHistory = async () => {
|
|
try {
|
|
const record = await client.loadHistory({
|
|
sessionKey: state.currentSessionKey,
|
|
limit: opts.historyLimit ?? 200
|
|
});
|
|
state.currentSessionId = typeof record.sessionId === "string" ? record.sessionId : null;
|
|
state.sessionInfo.thinkingLevel = record.thinkingLevel ?? state.sessionInfo.thinkingLevel;
|
|
state.sessionInfo.verboseLevel = record.verboseLevel ?? state.sessionInfo.verboseLevel;
|
|
const showTools = (state.sessionInfo.verboseLevel ?? "off") !== "off";
|
|
chatLog.clearAll();
|
|
chatLog.addSystem(`session ${state.currentSessionKey}`);
|
|
for (const entry of record.messages ?? []) {
|
|
if (!entry || typeof entry !== "object") continue;
|
|
const message = entry;
|
|
if (isCommandMessage(message)) {
|
|
const text = extractTextFromMessage(message);
|
|
if (text) chatLog.addSystem(text);
|
|
continue;
|
|
}
|
|
if (message.role === "user") {
|
|
const text = extractTextFromMessage(message);
|
|
if (text) chatLog.addUser(text);
|
|
continue;
|
|
}
|
|
if (message.role === "assistant") {
|
|
const text = extractTextFromMessage(message, { includeThinking: state.showThinking });
|
|
if (text) chatLog.finalizeAssistant(text);
|
|
continue;
|
|
}
|
|
if (message.role === "toolResult") {
|
|
if (!showTools) continue;
|
|
const toolCallId = asString(message.toolCallId, "");
|
|
const toolName = asString(message.toolName, "tool");
|
|
chatLog.startTool(toolCallId, toolName, {}).setResult({
|
|
content: Array.isArray(message.content) ? message.content : [],
|
|
details: typeof message.details === "object" && message.details ? message.details : void 0
|
|
}, { isError: Boolean(message.isError) });
|
|
}
|
|
}
|
|
state.historyLoaded = true;
|
|
} catch (err) {
|
|
chatLog.addSystem(`history failed: ${String(err)}`);
|
|
}
|
|
await refreshSessionInfo();
|
|
tui.requestRender();
|
|
};
|
|
const setSession = async (rawKey) => {
|
|
const nextKey = resolveSessionKey(rawKey);
|
|
updateAgentFromSessionKey(nextKey);
|
|
state.currentSessionKey = nextKey;
|
|
state.activeChatRunId = null;
|
|
state.currentSessionId = null;
|
|
state.historyLoaded = false;
|
|
clearLocalRunIds?.();
|
|
updateHeader();
|
|
updateFooter();
|
|
await loadHistory();
|
|
};
|
|
const abortActive = async () => {
|
|
if (!state.activeChatRunId) {
|
|
chatLog.addSystem("no active run");
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
try {
|
|
await client.abortChat({
|
|
sessionKey: state.currentSessionKey,
|
|
runId: state.activeChatRunId
|
|
});
|
|
setActivityStatus("aborted");
|
|
} catch (err) {
|
|
chatLog.addSystem(`abort failed: ${String(err)}`);
|
|
setActivityStatus("abort failed");
|
|
}
|
|
tui.requestRender();
|
|
};
|
|
return {
|
|
applyAgentsResult,
|
|
refreshAgents,
|
|
refreshSessionInfo,
|
|
applySessionInfoFromPatch,
|
|
loadHistory,
|
|
setSession,
|
|
abortActive
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui-waiting.ts
|
|
const defaultWaitingPhrases = [
|
|
"flibbertigibbeting",
|
|
"kerfuffling",
|
|
"dillydallying",
|
|
"twiddling thumbs",
|
|
"noodling",
|
|
"bamboozling",
|
|
"moseying",
|
|
"hobnobbing",
|
|
"pondering",
|
|
"conjuring"
|
|
];
|
|
function pickWaitingPhrase(tick, phrases = defaultWaitingPhrases) {
|
|
return phrases[Math.floor(tick / 10) % phrases.length] ?? phrases[0] ?? "waiting";
|
|
}
|
|
function shimmerText(theme, text, tick) {
|
|
const width = 6;
|
|
const hi = (ch) => theme.bold(theme.accentSoft(ch));
|
|
const pos = tick % (text.length + width);
|
|
const start = Math.max(0, pos - width);
|
|
const end = Math.min(text.length - 1, pos);
|
|
let out = "";
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
out += i >= start && i <= end ? hi(ch) : theme.dim(ch);
|
|
}
|
|
return out;
|
|
}
|
|
function buildWaitingStatusMessage(params) {
|
|
const phrase = pickWaitingPhrase(params.tick, params.phrases);
|
|
return `${shimmerText(params.theme, `${phrase}…`, params.tick)} • ${params.elapsed} | ${params.connectionStatus}`;
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/tui/tui.ts
|
|
function createEditorSubmitHandler(params) {
|
|
return (text) => {
|
|
const raw = text;
|
|
const value = raw.trim();
|
|
params.editor.setText("");
|
|
if (!value) return;
|
|
if (raw.startsWith("!") && raw !== "!") {
|
|
params.editor.addToHistory(raw);
|
|
params.handleBangLine(raw);
|
|
return;
|
|
}
|
|
params.editor.addToHistory(value);
|
|
if (value.startsWith("/")) {
|
|
params.handleCommand(value);
|
|
return;
|
|
}
|
|
params.sendMessage(value);
|
|
};
|
|
}
|
|
async function runTui(opts) {
|
|
const config = loadConfig();
|
|
const initialSessionInput = (opts.session ?? "").trim();
|
|
let sessionScope = config.session?.scope ?? "per-sender";
|
|
let sessionMainKey = normalizeMainKey(config.session?.mainKey);
|
|
let agentDefaultId = resolveDefaultAgentId(config);
|
|
let currentAgentId = agentDefaultId;
|
|
let agents = [];
|
|
const agentNames = /* @__PURE__ */ new Map();
|
|
let currentSessionKey = "";
|
|
let initialSessionApplied = false;
|
|
let currentSessionId = null;
|
|
let activeChatRunId = null;
|
|
let historyLoaded = false;
|
|
let isConnected = false;
|
|
let wasDisconnected = false;
|
|
let toolsExpanded = false;
|
|
let showThinking = false;
|
|
const localRunIds = /* @__PURE__ */ new Set();
|
|
const deliverDefault = opts.deliver ?? false;
|
|
const autoMessage = opts.message?.trim();
|
|
let autoMessageSent = false;
|
|
let sessionInfo = {};
|
|
let lastCtrlCAt = 0;
|
|
let activityStatus = "idle";
|
|
let connectionStatus = "connecting";
|
|
let statusTimeout = null;
|
|
let statusTimer = null;
|
|
let statusStartedAt = null;
|
|
let lastActivityStatus = activityStatus;
|
|
const state = {
|
|
get agentDefaultId() {
|
|
return agentDefaultId;
|
|
},
|
|
set agentDefaultId(value) {
|
|
agentDefaultId = value;
|
|
},
|
|
get sessionMainKey() {
|
|
return sessionMainKey;
|
|
},
|
|
set sessionMainKey(value) {
|
|
sessionMainKey = value;
|
|
},
|
|
get sessionScope() {
|
|
return sessionScope;
|
|
},
|
|
set sessionScope(value) {
|
|
sessionScope = value;
|
|
},
|
|
get agents() {
|
|
return agents;
|
|
},
|
|
set agents(value) {
|
|
agents = value;
|
|
},
|
|
get currentAgentId() {
|
|
return currentAgentId;
|
|
},
|
|
set currentAgentId(value) {
|
|
currentAgentId = value;
|
|
},
|
|
get currentSessionKey() {
|
|
return currentSessionKey;
|
|
},
|
|
set currentSessionKey(value) {
|
|
currentSessionKey = value;
|
|
},
|
|
get currentSessionId() {
|
|
return currentSessionId;
|
|
},
|
|
set currentSessionId(value) {
|
|
currentSessionId = value;
|
|
},
|
|
get activeChatRunId() {
|
|
return activeChatRunId;
|
|
},
|
|
set activeChatRunId(value) {
|
|
activeChatRunId = value;
|
|
},
|
|
get historyLoaded() {
|
|
return historyLoaded;
|
|
},
|
|
set historyLoaded(value) {
|
|
historyLoaded = value;
|
|
},
|
|
get sessionInfo() {
|
|
return sessionInfo;
|
|
},
|
|
set sessionInfo(value) {
|
|
sessionInfo = value;
|
|
},
|
|
get initialSessionApplied() {
|
|
return initialSessionApplied;
|
|
},
|
|
set initialSessionApplied(value) {
|
|
initialSessionApplied = value;
|
|
},
|
|
get isConnected() {
|
|
return isConnected;
|
|
},
|
|
set isConnected(value) {
|
|
isConnected = value;
|
|
},
|
|
get autoMessageSent() {
|
|
return autoMessageSent;
|
|
},
|
|
set autoMessageSent(value) {
|
|
autoMessageSent = value;
|
|
},
|
|
get toolsExpanded() {
|
|
return toolsExpanded;
|
|
},
|
|
set toolsExpanded(value) {
|
|
toolsExpanded = value;
|
|
},
|
|
get showThinking() {
|
|
return showThinking;
|
|
},
|
|
set showThinking(value) {
|
|
showThinking = value;
|
|
},
|
|
get connectionStatus() {
|
|
return connectionStatus;
|
|
},
|
|
set connectionStatus(value) {
|
|
connectionStatus = value;
|
|
},
|
|
get activityStatus() {
|
|
return activityStatus;
|
|
},
|
|
set activityStatus(value) {
|
|
activityStatus = value;
|
|
},
|
|
get statusTimeout() {
|
|
return statusTimeout;
|
|
},
|
|
set statusTimeout(value) {
|
|
statusTimeout = value;
|
|
},
|
|
get lastCtrlCAt() {
|
|
return lastCtrlCAt;
|
|
},
|
|
set lastCtrlCAt(value) {
|
|
lastCtrlCAt = value;
|
|
}
|
|
};
|
|
const noteLocalRunId = (runId) => {
|
|
if (!runId) return;
|
|
localRunIds.add(runId);
|
|
if (localRunIds.size > 200) {
|
|
const [first] = localRunIds;
|
|
if (first) localRunIds.delete(first);
|
|
}
|
|
};
|
|
const forgetLocalRunId = (runId) => {
|
|
localRunIds.delete(runId);
|
|
};
|
|
const isLocalRunId = (runId) => localRunIds.has(runId);
|
|
const clearLocalRunIds = () => {
|
|
localRunIds.clear();
|
|
};
|
|
const client = new GatewayChatClient({
|
|
url: opts.url,
|
|
token: opts.token,
|
|
password: opts.password
|
|
});
|
|
const tui = new TUI(new ProcessTerminal());
|
|
const header = new Text("", 1, 0);
|
|
const statusContainer = new Container();
|
|
const footer = new Text("", 1, 0);
|
|
const chatLog = new ChatLog();
|
|
const editor = new CustomEditor(tui, editorTheme);
|
|
const root = new Container();
|
|
root.addChild(header);
|
|
root.addChild(chatLog);
|
|
root.addChild(statusContainer);
|
|
root.addChild(footer);
|
|
root.addChild(editor);
|
|
const updateAutocompleteProvider = () => {
|
|
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(getSlashCommands({
|
|
cfg: config,
|
|
provider: sessionInfo.modelProvider,
|
|
model: sessionInfo.model
|
|
}), process.cwd()));
|
|
};
|
|
tui.addChild(root);
|
|
tui.setFocus(editor);
|
|
const formatSessionKey = (key) => {
|
|
if (key === "global" || key === "unknown") return key;
|
|
return parseAgentSessionKey(key)?.rest ?? key;
|
|
};
|
|
const formatAgentLabel = (id) => {
|
|
const name = agentNames.get(id);
|
|
return name ? `${id} (${name})` : id;
|
|
};
|
|
const resolveSessionKey = (raw) => {
|
|
const trimmed = (raw ?? "").trim();
|
|
if (sessionScope === "global") return "global";
|
|
if (!trimmed) return buildAgentMainSessionKey({
|
|
agentId: currentAgentId,
|
|
mainKey: sessionMainKey
|
|
});
|
|
if (trimmed === "global" || trimmed === "unknown") return trimmed;
|
|
if (trimmed.startsWith("agent:")) return trimmed;
|
|
return `agent:${currentAgentId}:${trimmed}`;
|
|
};
|
|
currentSessionKey = resolveSessionKey(initialSessionInput);
|
|
const updateHeader = () => {
|
|
const sessionLabel = formatSessionKey(currentSessionKey);
|
|
const agentLabel = formatAgentLabel(currentAgentId);
|
|
header.setText(theme.header(`openclaw tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`));
|
|
};
|
|
const busyStates = new Set([
|
|
"sending",
|
|
"waiting",
|
|
"streaming",
|
|
"running"
|
|
]);
|
|
let statusText = null;
|
|
let statusLoader = null;
|
|
const formatElapsed = (startMs) => {
|
|
const totalSeconds = Math.max(0, Math.floor((Date.now() - startMs) / 1e3));
|
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
|
|
};
|
|
const ensureStatusText = () => {
|
|
if (statusText) return;
|
|
statusContainer.clear();
|
|
statusLoader?.stop();
|
|
statusLoader = null;
|
|
statusText = new Text("", 1, 0);
|
|
statusContainer.addChild(statusText);
|
|
};
|
|
const ensureStatusLoader = () => {
|
|
if (statusLoader) return;
|
|
statusContainer.clear();
|
|
statusText = null;
|
|
statusLoader = new Loader(tui, (spinner) => theme.accent(spinner), (text) => theme.bold(theme.accentSoft(text)), "");
|
|
statusContainer.addChild(statusLoader);
|
|
};
|
|
let waitingTick = 0;
|
|
let waitingTimer = null;
|
|
let waitingPhrase = null;
|
|
const updateBusyStatusMessage = () => {
|
|
if (!statusLoader || !statusStartedAt) return;
|
|
const elapsed = formatElapsed(statusStartedAt);
|
|
if (activityStatus === "waiting") {
|
|
waitingTick++;
|
|
statusLoader.setMessage(buildWaitingStatusMessage({
|
|
theme,
|
|
tick: waitingTick,
|
|
elapsed,
|
|
connectionStatus,
|
|
phrases: waitingPhrase ? [waitingPhrase] : void 0
|
|
}));
|
|
return;
|
|
}
|
|
statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`);
|
|
};
|
|
const startStatusTimer = () => {
|
|
if (statusTimer) return;
|
|
statusTimer = setInterval(() => {
|
|
if (!busyStates.has(activityStatus)) return;
|
|
updateBusyStatusMessage();
|
|
}, 1e3);
|
|
};
|
|
const stopStatusTimer = () => {
|
|
if (!statusTimer) return;
|
|
clearInterval(statusTimer);
|
|
statusTimer = null;
|
|
};
|
|
const startWaitingTimer = () => {
|
|
if (waitingTimer) return;
|
|
if (!waitingPhrase) waitingPhrase = defaultWaitingPhrases[Math.floor(Math.random() * defaultWaitingPhrases.length)] ?? defaultWaitingPhrases[0] ?? "waiting";
|
|
waitingTick = 0;
|
|
waitingTimer = setInterval(() => {
|
|
if (activityStatus !== "waiting") return;
|
|
updateBusyStatusMessage();
|
|
}, 120);
|
|
};
|
|
const stopWaitingTimer = () => {
|
|
if (!waitingTimer) return;
|
|
clearInterval(waitingTimer);
|
|
waitingTimer = null;
|
|
waitingPhrase = null;
|
|
};
|
|
const renderStatus = () => {
|
|
if (busyStates.has(activityStatus)) {
|
|
if (!statusStartedAt || lastActivityStatus !== activityStatus) statusStartedAt = Date.now();
|
|
ensureStatusLoader();
|
|
if (activityStatus === "waiting") {
|
|
stopStatusTimer();
|
|
startWaitingTimer();
|
|
} else {
|
|
stopWaitingTimer();
|
|
startStatusTimer();
|
|
}
|
|
updateBusyStatusMessage();
|
|
} else {
|
|
statusStartedAt = null;
|
|
stopStatusTimer();
|
|
stopWaitingTimer();
|
|
statusLoader?.stop();
|
|
statusLoader = null;
|
|
ensureStatusText();
|
|
const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus;
|
|
statusText?.setText(theme.dim(text));
|
|
}
|
|
lastActivityStatus = activityStatus;
|
|
};
|
|
const setConnectionStatus = (text, ttlMs) => {
|
|
connectionStatus = text;
|
|
renderStatus();
|
|
if (statusTimeout) clearTimeout(statusTimeout);
|
|
if (ttlMs && ttlMs > 0) statusTimeout = setTimeout(() => {
|
|
connectionStatus = isConnected ? "connected" : "disconnected";
|
|
renderStatus();
|
|
}, ttlMs);
|
|
};
|
|
const setActivityStatus = (text) => {
|
|
activityStatus = text;
|
|
renderStatus();
|
|
};
|
|
const updateFooter = () => {
|
|
const sessionKeyLabel = formatSessionKey(currentSessionKey);
|
|
const sessionLabel = sessionInfo.displayName ? `${sessionKeyLabel} (${sessionInfo.displayName})` : sessionKeyLabel;
|
|
const agentLabel = formatAgentLabel(currentAgentId);
|
|
const modelLabel = sessionInfo.model ? sessionInfo.modelProvider ? `${sessionInfo.modelProvider}/${sessionInfo.model}` : sessionInfo.model : "unknown";
|
|
const tokens = formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null);
|
|
const think = sessionInfo.thinkingLevel ?? "off";
|
|
const verbose = sessionInfo.verboseLevel ?? "off";
|
|
const reasoning = sessionInfo.reasoningLevel ?? "off";
|
|
const reasoningLabel = reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null;
|
|
const footerParts = [
|
|
`agent ${agentLabel}`,
|
|
`session ${sessionLabel}`,
|
|
modelLabel,
|
|
think !== "off" ? `think ${think}` : null,
|
|
verbose !== "off" ? `verbose ${verbose}` : null,
|
|
reasoningLabel,
|
|
tokens
|
|
].filter(Boolean);
|
|
footer.setText(theme.dim(footerParts.join(" | ")));
|
|
};
|
|
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
|
|
const { refreshAgents, refreshSessionInfo, applySessionInfoFromPatch, loadHistory, setSession, abortActive } = createSessionActions({
|
|
client,
|
|
chatLog,
|
|
tui,
|
|
opts,
|
|
state,
|
|
agentNames,
|
|
initialSessionInput,
|
|
initialSessionAgentId: (() => {
|
|
if (!initialSessionInput) return null;
|
|
const parsed = parseAgentSessionKey(initialSessionInput);
|
|
return parsed ? normalizeAgentId(parsed.agentId) : null;
|
|
})(),
|
|
resolveSessionKey,
|
|
updateHeader,
|
|
updateFooter,
|
|
updateAutocompleteProvider,
|
|
setActivityStatus,
|
|
clearLocalRunIds
|
|
});
|
|
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
|
chatLog,
|
|
tui,
|
|
state,
|
|
setActivityStatus,
|
|
refreshSessionInfo,
|
|
loadHistory,
|
|
isLocalRunId,
|
|
forgetLocalRunId,
|
|
clearLocalRunIds
|
|
});
|
|
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } = createCommandHandlers({
|
|
client,
|
|
chatLog,
|
|
tui,
|
|
opts,
|
|
state,
|
|
deliverDefault,
|
|
openOverlay,
|
|
closeOverlay,
|
|
refreshSessionInfo,
|
|
applySessionInfoFromPatch,
|
|
loadHistory,
|
|
setSession,
|
|
refreshAgents,
|
|
abortActive,
|
|
setActivityStatus,
|
|
formatSessionKey,
|
|
noteLocalRunId,
|
|
forgetLocalRunId
|
|
});
|
|
const { runLocalShellLine } = createLocalShellRunner({
|
|
chatLog,
|
|
tui,
|
|
openOverlay,
|
|
closeOverlay
|
|
});
|
|
updateAutocompleteProvider();
|
|
editor.onSubmit = createEditorSubmitHandler({
|
|
editor,
|
|
handleCommand,
|
|
sendMessage,
|
|
handleBangLine: runLocalShellLine
|
|
});
|
|
editor.onEscape = () => {
|
|
abortActive();
|
|
};
|
|
editor.onCtrlC = () => {
|
|
const now = Date.now();
|
|
if (editor.getText().trim().length > 0) {
|
|
editor.setText("");
|
|
setActivityStatus("cleared input");
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
if (now - lastCtrlCAt < 1e3) {
|
|
client.stop();
|
|
tui.stop();
|
|
process.exit(0);
|
|
}
|
|
lastCtrlCAt = now;
|
|
setActivityStatus("press ctrl+c again to exit");
|
|
tui.requestRender();
|
|
};
|
|
editor.onCtrlD = () => {
|
|
client.stop();
|
|
tui.stop();
|
|
process.exit(0);
|
|
};
|
|
editor.onCtrlO = () => {
|
|
toolsExpanded = !toolsExpanded;
|
|
chatLog.setToolsExpanded(toolsExpanded);
|
|
setActivityStatus(toolsExpanded ? "tools expanded" : "tools collapsed");
|
|
tui.requestRender();
|
|
};
|
|
editor.onCtrlL = () => {
|
|
openModelSelector();
|
|
};
|
|
editor.onCtrlG = () => {
|
|
openAgentSelector();
|
|
};
|
|
editor.onCtrlP = () => {
|
|
openSessionSelector();
|
|
};
|
|
editor.onCtrlT = () => {
|
|
showThinking = !showThinking;
|
|
loadHistory();
|
|
};
|
|
client.onEvent = (evt) => {
|
|
if (evt.event === "chat") handleChatEvent(evt.payload);
|
|
if (evt.event === "agent") handleAgentEvent(evt.payload);
|
|
};
|
|
client.onConnected = () => {
|
|
isConnected = true;
|
|
const reconnected = wasDisconnected;
|
|
wasDisconnected = false;
|
|
setConnectionStatus("connected");
|
|
(async () => {
|
|
await refreshAgents();
|
|
updateHeader();
|
|
await loadHistory();
|
|
setConnectionStatus(reconnected ? "gateway reconnected" : "gateway connected", 4e3);
|
|
tui.requestRender();
|
|
if (!autoMessageSent && autoMessage) {
|
|
autoMessageSent = true;
|
|
await sendMessage(autoMessage);
|
|
}
|
|
updateFooter();
|
|
tui.requestRender();
|
|
})();
|
|
};
|
|
client.onDisconnected = (reason) => {
|
|
isConnected = false;
|
|
wasDisconnected = true;
|
|
historyLoaded = false;
|
|
setConnectionStatus(`gateway disconnected: ${reason?.trim() ? reason.trim() : "closed"}`, 5e3);
|
|
setActivityStatus("idle");
|
|
updateFooter();
|
|
tui.requestRender();
|
|
};
|
|
client.onGap = (info) => {
|
|
setConnectionStatus(`event gap: expected ${info.expected}, got ${info.received}`, 5e3);
|
|
tui.requestRender();
|
|
};
|
|
updateHeader();
|
|
setConnectionStatus("connecting");
|
|
updateFooter();
|
|
tui.start();
|
|
client.start();
|
|
await new Promise((resolve) => {
|
|
const finish = () => resolve();
|
|
process.once("exit", finish);
|
|
process.once("SIGINT", finish);
|
|
process.once("SIGTERM", finish);
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
export { runTui as t }; |