refactor(tui): split handlers
This commit is contained in:
441
src/tui/tui-command-handlers.ts
Normal file
441
src/tui/tui-command-handlers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
113
src/tui/tui-event-handlers.ts
Normal file
113
src/tui/tui-event-handlers.ts
Normal 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
89
src/tui/tui-formatters.ts
Normal 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;
|
||||
}
|
||||
241
src/tui/tui-session-actions.ts
Normal file
241
src/tui/tui-session-actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
src/tui/tui-status-summary.ts
Normal file
85
src/tui/tui-status-summary.ts
Normal 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
97
src/tui/tui-types.ts
Normal 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;
|
||||
};
|
||||
1082
src/tui/tui.ts
1082
src/tui/tui.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user