@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,504 lines (1,488 loc) • 101 kB
JavaScript
import { h as visibleWidth, m as stripAnsi } from "./entry.js";
import { i as buildAgentMainSessionKey, l as normalizeAgentId, u as normalizeMainKey, x as parseAgentSessionKey } from "./session-key-BGiG_JcT.js";
import { l as resolveDefaultAgentId } from "./agent-scope-RzK9Zcks.js";
import { V as VERSION, i as loadConfig } from "./config-B2kL1ciP.js";
import { kt as PROTOCOL_VERSION, t as GatewayClient } from "./client-CDjZdZtI.js";
import { i as ensureExplicitGatewayAuth, o as resolveExplicitGatewayAuth, t as buildGatewayConnectionDetails } from "./call-DXhJGwEy.js";
import { f as GATEWAY_CLIENT_CAPS, h as GATEWAY_CLIENT_NAMES, m as GATEWAY_CLIENT_MODES } from "./message-channel-CVHJDItx.js";
import { S as stripLeadingInboundMetadata } from "./sessions-BD5dyLxb.js";
import { _ as formatRawAssistantErrorForUi } from "./pi-embedded-helpers-NVEtaJwl.js";
import { c as normalizeUsageDisplay, r as listThinkingLevelLabels, t as formatThinkingLevels, u as resolveResponseUsageMode } from "./thinking-CJPPUYWd.js";
import { a as listChatCommandsForConfig, i as listChatCommands } from "./commands-registry-CRmMPJQ9.js";
import { n as resolveToolDisplay, t as formatToolDetail } from "./tool-display-Cs1uRaRV.js";
import { n as formatTimeAgo, t as formatRelativeTimestamp } from "./format-relative-TyajjYxu.js";
import { n as formatTokenCount } from "./usage-format-8m-0w7RC.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),
assistantText: (text) => 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 baseSelectListTheme = {
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 selectListTheme = baseSelectListTheme;
const filterableSelectListTheme = {
...baseSelectListTheme,
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 = {
...baseSelectListTheme,
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.assistantText(line) });
this.addChild(new Spacer(1));
this.addChild(this.body);
}
setText(text) {
this.body.setText(text);
}
};
//#endregion
//#region src/tui/tui-formatters.ts
const REPLACEMENT_CHAR_RE = /\uFFFD/g;
const MAX_TOKEN_CHARS = 32;
const LONG_TOKEN_RE = /\S{33,}/g;
const LONG_TOKEN_TEST_RE = /\S{33,}/;
const BINARY_LINE_REPLACEMENT_THRESHOLD = 12;
const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i;
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/;
function hasControlChars(text) {
for (const char of text) {
const code = char.charCodeAt(0);
if (code <= 31 && code !== 9 && code !== 10 && code !== 13 || code >= 127 && code <= 159) return true;
}
return false;
}
function stripControlChars(text) {
if (!hasControlChars(text)) return text;
let sanitized = "";
for (const char of text) {
const code = char.charCodeAt(0);
if (!(code <= 31 && code !== 9 && code !== 10 && code !== 13) && !(code >= 127 && code <= 159)) sanitized += char;
}
return sanitized;
}
function chunkToken(token, maxChars) {
if (token.length <= maxChars) return [token];
const chunks = [];
for (let i = 0; i < token.length; i += maxChars) chunks.push(token.slice(i, i + maxChars));
return chunks;
}
function isCopySensitiveToken(token) {
if (URL_PREFIX_RE.test(token)) return true;
if (token.startsWith("/") || token.startsWith("~/") || token.startsWith("./") || token.startsWith("../")) return true;
if (WINDOWS_DRIVE_RE.test(token) || token.startsWith("\\\\")) return true;
if (token.includes("/") || token.includes("\\")) return true;
return token.includes("_") && FILE_LIKE_RE.test(token);
}
function normalizeLongTokenForDisplay(token) {
if (isCopySensitiveToken(token)) return token;
return chunkToken(token, MAX_TOKEN_CHARS).join(" ");
}
function redactBinaryLikeLine(line) {
const replacementCount = (line.match(REPLACEMENT_CHAR_RE) || []).length;
if (replacementCount >= BINARY_LINE_REPLACEMENT_THRESHOLD && replacementCount * 2 >= line.length) return "[binary data omitted]";
return line;
}
function sanitizeRenderableText(text) {
if (!text) return text;
const hasAnsi = text.includes("\x1B");
const hasReplacementChars = text.includes("�");
const hasLongTokens = LONG_TOKEN_TEST_RE.test(text);
const hasControls = hasControlChars(text);
if (!hasAnsi && !hasReplacementChars && !hasLongTokens && !hasControls) return text;
const withoutAnsi = hasAnsi ? stripAnsi(text) : text;
const withoutControlChars = hasControls ? stripControlChars(withoutAnsi) : withoutAnsi;
const redacted = hasReplacementChars ? withoutControlChars.split("\n").map((line) => redactBinaryLikeLine(line)).join("\n") : withoutControlChars;
return LONG_TOKEN_TEST_RE.test(redacted) ? redacted.replace(LONG_TOKEN_RE, normalizeLongTokenForDisplay) : redacted;
}
function resolveFinalAssistantText(params) {
const finalText = params.finalText ?? "";
if (finalText.trim()) return finalText;
const streamedText = params.streamedText ?? "";
if (streamedText.trim()) return streamedText;
return "(no output)";
}
function composeThinkingAndContent(params) {
const thinkingText = params.thinkingText?.trim() ?? "";
const contentText = params.contentText?.trim() ?? "";
const parts = [];
if (params.showThinking && thinkingText) parts.push(`[thinking]\n${thinkingText}`);
if (contentText) parts.push(contentText);
return parts.join("\n\n").trim();
}
/**
* Extract ONLY thinking blocks from message content.
* Model-agnostic: returns empty string if no thinking blocks exist.
*/
function extractThinkingFromMessage(message) {
if (!message || typeof message !== "object") return "";
const content = message.content;
if (typeof content === "string") return "";
if (!Array.isArray(content)) return "";
const parts = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const rec = block;
if (rec.type === "thinking" && typeof rec.thinking === "string") parts.push(sanitizeRenderableText(rec.thinking));
}
return parts.join("\n").trim();
}
/**
* Extract ONLY text content blocks from message (excludes thinking).
* Model-agnostic: works for any model with text content blocks.
*/
function extractContentFromMessage(message) {
if (!message || typeof message !== "object") return "";
const record = message;
const content = record.content;
if (typeof content === "string") return sanitizeRenderableText(content).trim();
if (!Array.isArray(content)) {
if ((typeof record.stopReason === "string" ? record.stopReason : "") === "error") return formatRawAssistantErrorForUi(typeof record.errorMessage === "string" ? record.errorMessage : "");
return "";
}
const parts = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const rec = block;
if (rec.type === "text" && typeof rec.text === "string") parts.push(sanitizeRenderableText(rec.text));
}
if (parts.length === 0) {
if ((typeof record.stopReason === "string" ? record.stopReason : "") === "error") return formatRawAssistantErrorForUi(typeof record.errorMessage === "string" ? record.errorMessage : "");
}
return parts.join("\n").trim();
}
function extractTextBlocks(content, opts) {
if (typeof content === "string") return sanitizeRenderableText(content).trim();
if (!Array.isArray(content)) return "";
const thinkingParts = [];
const textParts = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const record = block;
if (record.type === "text" && typeof record.text === "string") textParts.push(sanitizeRenderableText(record.text));
if (opts?.includeThinking && record.type === "thinking" && typeof record.thinking === "string") thinkingParts.push(sanitizeRenderableText(record.thinking));
}
return composeThinkingAndContent({
thinkingText: thinkingParts.join("\n").trim(),
contentText: textParts.join("\n").trim(),
showThinking: opts?.includeThinking ?? false
});
}
function extractTextFromMessage(message, opts) {
if (!message || typeof message !== "object") return "";
const record = message;
const text = extractTextBlocks(record.content, opts);
if (text) {
if (record.role === "user") return stripLeadingInboundMetadata(text);
return text;
}
if ((typeof record.stopReason === "string" ? record.stopReason : "") !== "error") return "";
return formatRawAssistantErrorForUi(typeof record.errorMessage === "string" ? record.errorMessage : "");
}
function isCommandMessage(message) {
if (!message || typeof message !== "object") return false;
return message.command === true;
}
function formatTokens(total, context) {
if (total == null && context == null) return "tokens ?";
const totalLabel = total == null ? "?" : formatTokenCount(total);
if (context == null) return `tokens ${totalLabel}`;
const pct = typeof total === "number" && context > 0 ? Math.min(999, Math.round(total / context * 100)) : null;
return `tokens ${totalLabel}/${formatTokenCount(context)}${pct !== null ? ` (${pct}%)` : ""}`;
}
function formatContextUsageLine(params) {
const totalLabel = typeof params.total === "number" ? formatTokenCount(params.total) : "?";
const ctxLabel = typeof params.context === "number" ? formatTokenCount(params.context) : "?";
const pct = typeof params.percent === "number" ? Math.min(999, Math.round(params.percent)) : null;
const extra = [typeof params.remaining === "number" ? `${formatTokenCount(params.remaining)} left` : null, pct !== null ? `${pct}%` : null].filter(Boolean).join(", ");
return `tokens ${totalLabel}/${ctxLabel}${extra ? ` (${extra})` : ""}`;
}
function asString(value, fallback = "") {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
//#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 sanitizeRenderableText(detail);
if (!args || typeof args !== "object") return "";
try {
return sanitizeRenderableText(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(sanitizeRenderableText(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(maxComponents = 180) {
super();
this.toolById = /* @__PURE__ */ new Map();
this.streamingRuns = /* @__PURE__ */ new Map();
this.toolsExpanded = false;
this.maxComponents = Math.max(20, Math.floor(maxComponents));
}
dropComponentReferences(component) {
for (const [toolId, tool] of this.toolById.entries()) if (tool === component) this.toolById.delete(toolId);
for (const [runId, message] of this.streamingRuns.entries()) if (message === component) this.streamingRuns.delete(runId);
}
pruneOverflow() {
while (this.children.length > this.maxComponents) {
const oldest = this.children[0];
if (!oldest) return;
this.removeChild(oldest);
this.dropComponentReferences(oldest);
}
}
append(component) {
this.addChild(component);
this.pruneOverflow();
}
clearAll() {
this.clear();
this.toolById.clear();
this.streamingRuns.clear();
}
addSystem(text) {
this.append(new Spacer(1));
this.append(new Text(theme.system(text), 1, 0));
}
addUser(text) {
this.append(new UserMessageComponent(text));
}
resolveRunId(runId) {
return runId ?? "default";
}
startAssistant(text, runId) {
const component = new AssistantMessageComponent(text);
this.streamingRuns.set(this.resolveRunId(runId), component);
this.append(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.append(new AssistantMessageComponent(text));
}
dropAssistant(runId) {
const effectiveRunId = this.resolveRunId(runId);
const existing = this.streamingRuns.get(effectiveRunId);
if (!existing) return;
this.removeChild(existing);
this.streamingRuns.delete(effectiveRunId);
}
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.append(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, reason) {
return await this.client.request("sessions.reset", {
key,
...reason ? { reason } : {}
});
}
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 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: buildGatewayConnectionDetails({
config,
...urlOverride ? { url: urlOverride } : {}
}).url,
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/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
const ANSI_ESCAPE = String.fromCharCode(27);
const ANSI_SGR_REGEX = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, "g");
/**
* A select list with a search input at the top for fuzzy filtering.
*/
var SearchableSelectList = class SearchableSelectList {
static {
this.DESCRIPTION_LAYOUT_MIN_WIDTH = 40;
}
static {
this.DESCRIPTION_MIN_WIDTH = 12;
}
static {
this.DESCRIPTION_SPACING_WIDTH = 2;
}
static {
this.RIGHT_MARGIN_WIDTH = 2;
}
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 rawLabel = this.getItemLabel(item);
const rawDesc = item.description ?? "";
const label = stripAnsi(rawLabel).toLowerCase();
const desc = stripAnsi(rawDesc).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;
}
const searchText = item.searchText ?? "";
fuzzyCandidates.push({
item,
searchTextLower: [
rawLabel,
rawDesc,
searchText
].map((value) => stripAnsi(value)).filter(Boolean).join(" ").toLowerCase()
});
}
scoredItems.sort(this.compareByScore);
const fuzzyMatches = fuzzyFilterLower(fuzzyCandidates, q);
return [...scoredItems.map((s) => s.item), ...fuzzyMatches.map((entry) => entry.item)];
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
getItemLabel(item) {
return item.label || item.value;
}
splitAnsiParts(text) {
const parts = [];
ANSI_SGR_REGEX.lastIndex = 0;
let lastIndex = 0;
let match;
while ((match = ANSI_SGR_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) parts.push({
text: text.slice(lastIndex, match.index),
isAnsi: false
});
parts.push({
text: match[0],
isAnsi: true
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) parts.push({
text: text.slice(lastIndex),
isAnsi: false
});
return parts;
}
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 parts = this.splitAnsiParts(text);
for (const token of uniqueTokens) {
const regex = this.getCachedRegex(token);
const nextParts = [];
for (const part of parts) {
if (part.isAnsi) {
nextParts.push(part);
continue;
}
regex.lastIndex = 0;
const replaced = part.text.replace(regex, (match) => this.theme.matchHighlight(match));
if (replaced === part.text) {
nextParts.push(part);
continue;
}
nextParts.push(...this.splitAnsiParts(replaced));
}
parts = nextParts;
}
return parts.map((part) => part.text).join("");
}
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);
const description = item.description;
if (description) {
const descriptionLayout = this.getDescriptionLayout(width, prefixWidth);
if (descriptionLayout) {
const truncatedValue = truncateToWidth(displayValue, descriptionLayout.maxValueWidth, "");
const valueText = this.highlightMatch(truncatedValue, query);
const usedByValue = visibleWidth(valueText);
const descriptionWidth = descriptionLayout.availableWidth - usedByValue - descriptionLayout.spacingWidth;
if (descriptionWidth >= SearchableSelectList.DESCRIPTION_MIN_WIDTH) {
const spacing = " ".repeat(descriptionLayout.spacingWidth);
const truncatedDesc = truncateToWidth(description, descriptionWidth, "");
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;
}
getDescriptionLayout(width, prefixWidth) {
if (width <= SearchableSelectList.DESCRIPTION_LAYOUT_MIN_WIDTH) return null;
const availableWidth = Math.max(1, width - prefixWidth - SearchableSelectList.RIGHT_MARGIN_WIDTH);
const maxValueWidth = availableWidth - SearchableSelectList.DESCRIPTION_MIN_WIDTH - SearchableSelectList.DESCRIPTION_SPACING_WIDTH;
if (maxValueWidth < 1) return null;
return {
availableWidth,
maxValueWidth,
spacingWidth: SearchableSelectList.DESCRIPTION_SPACING_WIDTH
};
}
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 ${formatTimeAgo(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" ? formatTimeAgo(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 =