refactor(tui): split handlers

This commit is contained in:
Peter Steinberger
2026-01-14 09:11:28 +00:00
parent d19bc1562b
commit 32cfc49002
7 changed files with 1249 additions and 899 deletions

View File

@@ -0,0 +1,441 @@
import type { Component, TUI } from "@mariozechner/pi-tui";
import {
formatThinkingLevels,
normalizeUsageDisplay,
} from "../auto-reply/thinking.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js";
import {
createSelectList,
createSettingsList,
} from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import { formatStatusSummary } from "./tui-status-summary.js";
import type {
AgentSummary,
GatewayStatusSummary,
TuiOptions,
TuiStateAccess,
} from "./tui-types.js";
type CommandHandlerContext = {
client: GatewayChatClient;
chatLog: ChatLog;
tui: TUI;
opts: TuiOptions;
state: TuiStateAccess;
deliverDefault: boolean;
openOverlay: (component: Component) => void;
closeOverlay: () => void;
refreshSessionInfo: () => Promise<void>;
loadHistory: () => Promise<void>;
setSession: (key: string) => Promise<void>;
refreshAgents: () => Promise<void>;
abortActive: () => Promise<void>;
setActivityStatus: (text: string) => void;
formatSessionKey: (key: string) => string;
};
export function createCommandHandlers(context: CommandHandlerContext) {
const {
client,
chatLog,
tui,
opts,
state,
deliverDefault,
openOverlay,
closeOverlay,
refreshSessionInfo,
loadHistory,
setSession,
refreshAgents,
abortActive,
setActivityStatus,
formatSessionKey,
} = context;
const setAgent = async (id: string) => {
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 items = models.map((model) => ({
value: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.id}`,
description: model.name && model.name !== model.id ? model.name : "",
}));
const selector = createSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
try {
await client.patchSession({
key: state.currentSessionKey,
model: item.value,
});
chatLog.addSystem(`model set to ${item.value}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
}
closeOverlay();
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
} catch (err) {
chatLog.addSystem(`model list failed: ${String(err)}`);
tui.requestRender();
}
};
const openAgentSelector = async () => {
await refreshAgents();
if (state.agents.length === 0) {
chatLog.addSystem("no agents found");
tui.requestRender();
return;
}
const items = state.agents.map((agent: AgentSummary) => ({
value: agent.id,
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
description: agent.id === state.agentDefaultId ? "default" : "",
}));
const selector = createSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();
await setAgent(item.value);
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
};
const openSessionSelector = async () => {
try {
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: state.currentAgentId,
});
const items = result.sessions.map((session) => ({
value: session.key,
label: session.displayName
? `${session.displayName} (${formatSessionKey(session.key)})`
: formatSessionKey(session.key),
description: session.updatedAt
? new Date(session.updatedAt).toLocaleString()
: "",
}));
const selector = createSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();
await setSession(item.value);
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
tui.requestRender();
}
};
const openSettings = () => {
const items = [
{
id: "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"],
},
];
const settings = createSettingsList(
items,
(id, value) => {
if (id === "tools") {
state.toolsExpanded = value === "expanded";
chatLog.setToolsExpanded(state.toolsExpanded);
}
if (id === "thinking") {
state.showThinking = value === "on";
void loadHistory();
}
tui.requestRender();
},
() => {
closeOverlay();
tui.requestRender();
},
);
openOverlay(settings);
tui.requestRender();
};
const handleCommand = async (raw: string) => {
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 as GatewayStatusSummary);
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 {
await client.patchSession({
key: state.currentSessionKey,
model: args,
});
chatLog.addSystem(`model set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
}
}
break;
case "models":
await openModelSelector();
break;
case "think":
if (!args) {
const levels = formatThinkingLevels(
state.sessionInfo.modelProvider,
state.sessionInfo.model,
"|",
);
chatLog.addSystem(`usage: /think <${levels}>`);
break;
}
try {
await client.patchSession({
key: state.currentSessionKey,
thinkingLevel: args,
});
chatLog.addSystem(`thinking set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`think failed: ${String(err)}`);
}
break;
case "verbose":
if (!args) {
chatLog.addSystem("usage: /verbose <on|off>");
break;
}
try {
await client.patchSession({
key: state.currentSessionKey,
verboseLevel: args,
});
chatLog.addSystem(`verbose set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`verbose failed: ${String(err)}`);
}
break;
case "reasoning":
if (!args) {
chatLog.addSystem("usage: /reasoning <on|off>");
break;
}
try {
await client.patchSession({
key: state.currentSessionKey,
reasoningLevel: args,
});
chatLog.addSystem(`reasoning set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`reasoning failed: ${String(err)}`);
}
break;
case "cost": {
const normalized = args ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized) {
chatLog.addSystem("usage: /cost <on|off>");
break;
}
const current = state.sessionInfo.responseUsage === "on" ? "on" : "off";
const next = normalized ?? (current === "on" ? "off" : "on");
try {
await client.patchSession({
key: state.currentSessionKey,
responseUsage: next === "off" ? null : next,
});
chatLog.addSystem(
next === "on" ? "usage line enabled" : "usage line disabled",
);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`cost failed: ${String(err)}`);
}
break;
}
case "elevated":
if (!args) {
chatLog.addSystem("usage: /elevated <on|off>");
break;
}
try {
await client.patchSession({
key: state.currentSessionKey,
elevatedLevel: args,
});
chatLog.addSystem(`elevated set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`elevated failed: ${String(err)}`);
}
break;
case "activation":
if (!args) {
chatLog.addSystem("usage: /activation <mention|always>");
break;
}
try {
await client.patchSession({
key: state.currentSessionKey,
groupActivation: args === "always" ? "always" : "mention",
});
chatLog.addSystem(`activation set to ${args}`);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`activation failed: ${String(err)}`);
}
break;
case "new":
case "reset":
try {
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:
chatLog.addSystem(`unknown command: /${name}`);
break;
}
tui.requestRender();
};
const sendMessage = async (text: string) => {
try {
chatLog.addUser(text);
tui.requestRender();
setActivityStatus("sending");
const { runId } = await client.sendChat({
sessionKey: state.currentSessionKey,
message: text,
thinking: opts.thinking,
deliver: deliverDefault,
timeoutMs: opts.timeoutMs,
});
state.activeChatRunId = runId;
setActivityStatus("waiting");
} catch (err) {
chatLog.addSystem(`send failed: ${String(err)}`);
setActivityStatus("error");
}
tui.requestRender();
};
return {
handleCommand,
sendMessage,
openModelSelector,
openAgentSelector,
openSessionSelector,
openSettings,
setAgent,
};
}

View File

@@ -0,0 +1,113 @@
import type { TUI } from "@mariozechner/pi-tui";
import type { ChatLog } from "./components/chat-log.js";
import {
asString,
extractTextFromMessage,
resolveFinalAssistantText,
} from "./tui-formatters.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
type EventHandlerContext = {
chatLog: ChatLog;
tui: TUI;
state: TuiStateAccess;
setActivityStatus: (text: string) => void;
};
export function createEventHandlers(context: EventHandlerContext) {
const { chatLog, tui, state, setActivityStatus } = context;
const finalizedRuns = new Map<string, number>();
const noteFinalizedRun = (runId: string) => {
finalizedRuns.set(runId, Date.now());
if (finalizedRuns.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of finalizedRuns) {
if (finalizedRuns.size <= 150) break;
if (ts < keepUntil) finalizedRuns.delete(key);
}
if (finalizedRuns.size > 200) {
for (const key of finalizedRuns.keys()) {
finalizedRuns.delete(key);
if (finalizedRuns.size <= 150) break;
}
}
};
const handleChatEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as ChatEvent;
if (evt.sessionKey !== state.currentSessionKey) return;
if (finalizedRuns.has(evt.runId)) {
if (evt.state === "delta") return;
if (evt.state === "final") return;
}
if (evt.state === "delta") {
const text = extractTextFromMessage(evt.message, {
includeThinking: state.showThinking,
});
if (!text) return;
chatLog.updateAssistant(text, evt.runId);
setActivityStatus("streaming");
}
if (evt.state === "final") {
const text = extractTextFromMessage(evt.message, {
includeThinking: state.showThinking,
});
const finalText = resolveFinalAssistantText({
finalText: text,
streamedText: chatLog.getStreamingText(evt.runId),
});
chatLog.finalizeAssistant(finalText, evt.runId);
noteFinalizedRun(evt.runId);
state.activeChatRunId = null;
setActivityStatus("idle");
}
if (evt.state === "aborted") {
chatLog.addSystem("run aborted");
state.activeChatRunId = null;
setActivityStatus("aborted");
}
if (evt.state === "error") {
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
state.activeChatRunId = null;
setActivityStatus("error");
}
tui.requestRender();
};
const handleAgentEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as AgentEvent;
if (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
if (evt.stream === "tool") {
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") {
chatLog.updateToolResult(toolCallId, data.partialResult, {
partial: true,
});
} else if (phase === "result") {
chatLog.updateToolResult(toolCallId, data.result, {
isError: Boolean(data.isError),
});
}
tui.requestRender();
return;
}
if (evt.stream === "lifecycle") {
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 };
}

89
src/tui/tui-formatters.ts Normal file
View File

@@ -0,0 +1,89 @@
import { formatTokenCount } from "../utils/usage-format.js";
export function resolveFinalAssistantText(params: {
finalText?: string | null;
streamedText?: string | null;
}) {
const finalText = params.finalText ?? "";
if (finalText.trim()) return finalText;
const streamedText = params.streamedText ?? "";
if (streamedText.trim()) return streamedText;
return "(no output)";
}
function extractTextBlocks(
content: unknown,
opts?: { includeThinking?: boolean },
): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const record = block as Record<string, unknown>;
if (record.type === "text" && typeof record.text === "string") {
parts.push(record.text);
}
if (
opts?.includeThinking &&
record.type === "thinking" &&
typeof record.thinking === "string"
) {
parts.push(`[thinking]\n${record.thinking}`);
}
}
return parts.join("\n").trim();
}
export function extractTextFromMessage(
message: unknown,
opts?: { includeThinking?: boolean },
): string {
if (!message || typeof message !== "object") return "";
const record = message as Record<string, unknown>;
return extractTextBlocks(record.content, opts);
}
export function formatTokens(total?: number | null, context?: number | null) {
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}%)` : ""
}`;
}
export function formatContextUsageLine(params: {
total?: number | null;
context?: number | null;
remaining?: number | null;
percent?: number | null;
}) {
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 remainingLabel =
typeof params.remaining === "number"
? `${formatTokenCount(params.remaining)} left`
: null;
const pctLabel = pct !== null ? `${pct}%` : null;
const extra = [remainingLabel, pctLabel].filter(Boolean).join(", ");
return `tokens ${totalLabel}/${ctxLabel}${extra ? ` (${extra})` : ""}`;
}
export function asString(value: unknown, fallback = ""): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return fallback;
}

View File

@@ -0,0 +1,241 @@
import type { TUI } from "@mariozechner/pi-tui";
import {
normalizeAgentId,
normalizeMainKey,
parseAgentSessionKey,
} from "../routing/session-key.js";
import type { ChatLog } from "./components/chat-log.js";
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import { asString, extractTextFromMessage } from "./tui-formatters.js";
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
type SessionActionContext = {
client: GatewayChatClient;
chatLog: ChatLog;
tui: TUI;
opts: TuiOptions;
state: TuiStateAccess;
agentNames: Map<string, string>;
initialSessionInput: string;
initialSessionAgentId: string | null;
resolveSessionKey: (raw?: string) => string;
updateHeader: () => void;
updateFooter: () => void;
updateAutocompleteProvider: () => void;
setActivityStatus: (text: string) => void;
};
export function createSessionActions(context: SessionActionContext) {
const {
client,
chatLog,
tui,
opts,
state,
agentNames,
initialSessionInput,
initialSessionAgentId,
resolveSessionKey,
updateHeader,
updateFooter,
updateAutocompleteProvider,
setActivityStatus,
} = context;
const applyAgentsResult = (result: GatewayAgentsList) => {
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() || undefined,
}));
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 {
const result = await client.listAgents();
applyAgentsResult(result);
} catch (err) {
chatLog.addSystem(`agents list failed: ${String(err)}`);
}
};
const updateAgentFromSessionKey = (key: string) => {
const parsed = parseAgentSessionKey(key);
if (!parsed) return;
const next = normalizeAgentId(parsed.agentId);
if (next !== state.currentAgentId) {
state.currentAgentId = next;
}
};
const refreshSessionInfo = async () => {
try {
const listAgentId =
state.currentSessionKey === "global" ||
state.currentSessionKey === "unknown"
? undefined
: state.currentAgentId;
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: listAgentId,
});
const entry = result.sessions.find((row) => {
// Exact match
if (row.key === state.currentSessionKey) return true;
// Also match canonical keys like "agent:default:main" against "main"
const parsed = parseAgentSessionKey(row.key);
return parsed?.rest === state.currentSessionKey;
});
state.sessionInfo = {
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
model: entry?.model ?? result.defaults?.model ?? undefined,
modelProvider: entry?.modelProvider,
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
inputTokens: entry?.inputTokens ?? null,
outputTokens: entry?.outputTokens ?? null,
totalTokens: entry?.totalTokens ?? null,
responseUsage: entry?.responseUsage,
updatedAt: entry?.updatedAt ?? null,
displayName: entry?.displayName,
};
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
}
updateAutocompleteProvider();
updateFooter();
tui.requestRender();
};
const loadHistory = async () => {
try {
const history = await client.loadHistory({
sessionKey: state.currentSessionKey,
limit: opts.historyLimit ?? 200,
});
const record = history as {
messages?: unknown[];
sessionId?: string;
thinkingLevel?: string;
};
state.currentSessionId =
typeof record.sessionId === "string" ? record.sessionId : null;
state.sessionInfo.thinkingLevel =
record.thinkingLevel ?? state.sessionInfo.thinkingLevel;
chatLog.clearAll();
chatLog.addSystem(`session ${state.currentSessionKey}`);
for (const entry of record.messages ?? []) {
if (!entry || typeof entry !== "object") continue;
const message = entry as Record<string, unknown>;
if (message.role === "user") {
const text = extractTextFromMessage(message);
if (text) chatLog.addUser(text);
continue;
}
if (message.role === "assistant") {
const text = extractTextFromMessage(message, {
includeThinking: state.showThinking,
});
if (text) chatLog.finalizeAssistant(text);
continue;
}
if (message.role === "toolResult") {
const toolCallId = asString(message.toolCallId, "");
const toolName = asString(message.toolName, "tool");
const component = chatLog.startTool(toolCallId, toolName, {});
component.setResult(
{
content: Array.isArray(message.content)
? (message.content as Record<string, unknown>[])
: [],
details:
typeof message.details === "object" && message.details
? (message.details as Record<string, unknown>)
: undefined,
},
{ isError: Boolean(message.isError) },
);
}
}
state.historyLoaded = true;
} catch (err) {
chatLog.addSystem(`history failed: ${String(err)}`);
}
await refreshSessionInfo();
tui.requestRender();
};
const setSession = async (rawKey: string) => {
const nextKey = resolveSessionKey(rawKey);
updateAgentFromSessionKey(nextKey);
state.currentSessionKey = nextKey;
state.activeChatRunId = null;
state.currentSessionId = null;
state.historyLoaded = false;
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,
loadHistory,
setSession,
abortActive,
};
}

View File

@@ -0,0 +1,85 @@
import { formatAge } from "../infra/channel-summary.js";
import { formatTokenCount } from "../utils/usage-format.js";
import { formatContextUsageLine } from "./tui-formatters.js";
import type { GatewayStatusSummary } from "./tui-types.js";
export function formatStatusSummary(summary: GatewayStatusSummary) {
const lines: string[] = [];
lines.push("Gateway status");
if (!summary.linkProvider) {
lines.push("Link provider: unknown");
} else {
const linkLabel = summary.linkProvider.label ?? "Link provider";
const linked = summary.linkProvider.linked === true;
const authAge =
linked && typeof summary.linkProvider.authAgeMs === "number"
? ` (last refreshed ${formatAge(summary.linkProvider.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}`);
}
}
if (typeof summary.heartbeatSeconds === "number") {
lines.push("");
lines.push(`Heartbeat: ${summary.heartbeatSeconds}s`);
}
const sessionPath = summary.sessions?.path;
if (sessionPath) lines.push(`Session store: ${sessionPath}`);
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;
}

97
src/tui/tui-types.ts Normal file
View File

@@ -0,0 +1,97 @@
export type TuiOptions = {
url?: string;
token?: string;
password?: string;
session?: string;
deliver?: boolean;
thinking?: string;
timeoutMs?: number;
historyLimit?: number;
message?: string;
};
export type ChatEvent = {
runId: string;
sessionKey: string;
state: "delta" | "final" | "aborted" | "error";
message?: unknown;
errorMessage?: string;
};
export type AgentEvent = {
runId: string;
stream: string;
data?: Record<string, unknown>;
};
export type SessionInfo = {
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
model?: string;
modelProvider?: string;
contextTokens?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
responseUsage?: "on" | "off";
updatedAt?: number | null;
displayName?: string;
};
export type SessionScope = "per-sender" | "global";
export type AgentSummary = {
id: string;
name?: string;
};
export type GatewayStatusSummary = {
linkProvider?: {
label?: string;
linked?: boolean;
authAgeMs?: number | null;
};
heartbeatSeconds?: number;
providerSummary?: string[];
queuedSystemEvents?: string[];
sessions?: {
path?: string;
count?: number;
defaults?: { model?: string | null; contextTokens?: number | null };
recent?: Array<{
key: string;
kind?: string;
updatedAt?: number | null;
age?: number | null;
model?: string | null;
totalTokens?: number | null;
contextTokens?: number | null;
remainingTokens?: number | null;
percentUsed?: number | null;
flags?: string[];
}>;
};
};
export type TuiStateAccess = {
agentDefaultId: string;
sessionMainKey: string;
sessionScope: SessionScope;
agents: AgentSummary[];
currentAgentId: string;
currentSessionKey: string;
currentSessionId: string | null;
activeChatRunId: string | null;
historyLoaded: boolean;
sessionInfo: SessionInfo;
initialSessionApplied: boolean;
isConnected: boolean;
autoMessageSent: boolean;
toolsExpanded: boolean;
showThinking: boolean;
connectionStatus: string;
activityStatus: string;
statusTimeout: ReturnType<typeof setTimeout> | null;
lastCtrlCAt: number;
};

File diff suppressed because it is too large Load Diff