From 32cfc49002735f474176dc0af460e5b214060fff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 14 Jan 2026 09:11:28 +0000 Subject: [PATCH] refactor(tui): split handlers --- src/tui/tui-command-handlers.ts | 441 +++++++++++++ src/tui/tui-event-handlers.ts | 113 ++++ src/tui/tui-formatters.ts | 89 +++ src/tui/tui-session-actions.ts | 241 +++++++ src/tui/tui-status-summary.ts | 85 +++ src/tui/tui-types.ts | 97 +++ src/tui/tui.ts | 1082 ++++++------------------------- 7 files changed, 1249 insertions(+), 899 deletions(-) create mode 100644 src/tui/tui-command-handlers.ts create mode 100644 src/tui/tui-event-handlers.ts create mode 100644 src/tui/tui-formatters.ts create mode 100644 src/tui/tui-session-actions.ts create mode 100644 src/tui/tui-status-summary.ts create mode 100644 src/tui/tui-types.ts diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts new file mode 100644 index 000000000..ad89b12a6 --- /dev/null +++ b/src/tui/tui-command-handlers.ts @@ -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; + loadHistory: () => Promise; + setSession: (key: string) => Promise; + refreshAgents: () => Promise; + abortActive: () => Promise; + 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 "); + 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 "); + 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 "); + 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 "); + 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 "); + 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, + }; +} diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts new file mode 100644 index 000000000..9055a60b9 --- /dev/null +++ b/src/tui/tui-event-handlers.ts @@ -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(); + + 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 }; +} diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts new file mode 100644 index 000000000..3d45e1ec1 --- /dev/null +++ b/src/tui/tui-formatters.ts @@ -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; + 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; + 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; +} diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts new file mode 100644 index 000000000..14d3a6649 --- /dev/null +++ b/src/tui/tui-session-actions.ts @@ -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; + 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; + 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[]) + : [], + details: + typeof message.details === "object" && message.details + ? (message.details as Record) + : 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, + }; +} diff --git a/src/tui/tui-status-summary.ts b/src/tui/tui-status-summary.ts new file mode 100644 index 000000000..5758b89e9 --- /dev/null +++ b/src/tui/tui-status-summary.ts @@ -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; +} diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts new file mode 100644 index 000000000..22ec1c365 --- /dev/null +++ b/src/tui/tui-types.ts @@ -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; +}; + +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 | null; + lastCtrlCAt: number; +}; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 0485fa758..9f34a86cd 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -7,192 +7,32 @@ import { TUI, } from "@mariozechner/pi-tui"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { - formatThinkingLevels, - normalizeUsageDisplay, -} from "../auto-reply/thinking.js"; import { loadConfig } from "../config/config.js"; -import { formatAge } from "../infra/channel-summary.js"; import { buildAgentMainSessionKey, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, } from "../routing/session-key.js"; -import { formatTokenCount } from "../utils/usage-format.js"; -import { getSlashCommands, helpText, parseCommand } from "./commands.js"; +import { getSlashCommands } from "./commands.js"; import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; -import { - createSelectList, - createSettingsList, -} from "./components/selectors.js"; -import { type GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; +import { GatewayChatClient } from "./gateway-chat.js"; import { editorTheme, theme } from "./theme/theme.js"; +import { createCommandHandlers } from "./tui-command-handlers.js"; +import { createEventHandlers } from "./tui-event-handlers.js"; +import { formatTokens } from "./tui-formatters.js"; +import { createSessionActions } from "./tui-session-actions.js"; +import type { + AgentSummary, + SessionInfo, + SessionScope, + TuiOptions, + TuiStateAccess, +} from "./tui-types.js"; -export type TuiOptions = { - url?: string; - token?: string; - password?: string; - session?: string; - deliver?: boolean; - thinking?: string; - timeoutMs?: number; - historyLimit?: number; - message?: string; -}; - -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)"; -} - -type ChatEvent = { - runId: string; - sessionKey: string; - state: "delta" | "final" | "aborted" | "error"; - message?: unknown; - errorMessage?: string; -}; - -type AgentEvent = { - runId: string; - stream: string; - data?: Record; -}; - -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; -}; - -type SessionScope = "per-sender" | "global"; - -type AgentSummary = { - id: string; - name?: string; -}; - -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[]; - }>; - }; -}; - -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; - 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(); -} - -function extractTextFromMessage( - message: unknown, - opts?: { includeThinking?: boolean }, -): string { - if (!message || typeof message !== "object") return ""; - const record = message as Record; - return extractTextBlocks(record.content, opts); -} - -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}%)` : "" - }`; -} - -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})` : ""}`; -} - -function asString(value: unknown, fallback = ""): string { - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return fallback; -} +export { resolveFinalAssistantText } from "./tui-formatters.js"; +export type { TuiOptions } from "./tui-types.js"; export async function runTui(opts: TuiOptions) { const config = loadConfig(); @@ -208,7 +48,6 @@ export async function runTui(opts: TuiOptions) { let initialSessionApplied = false; let currentSessionId: string | null = null; let activeChatRunId: string | null = null; - const finalizedRuns = new Map(); let historyLoaded = false; let isConnected = false; let toolsExpanded = false; @@ -222,6 +61,123 @@ export async function runTui(opts: TuiOptions) { let connectionStatus = "connecting"; let statusTimeout: NodeJS.Timeout | null = null; + const state: TuiStateAccess = { + get agentDefaultId() { + return agentDefaultId; + }, + set agentDefaultId(value) { + agentDefaultId = value; + }, + get sessionMainKey() { + return sessionMainKey; + }, + set sessionMainKey(value) { + sessionMainKey = value; + }, + get sessionScope() { + return sessionScope; + }, + set sessionScope(value) { + sessionScope = value; + }, + get agents() { + return agents; + }, + set agents(value) { + agents = value; + }, + get currentAgentId() { + return currentAgentId; + }, + set currentAgentId(value) { + currentAgentId = value; + }, + get currentSessionKey() { + return currentSessionKey; + }, + set currentSessionKey(value) { + currentSessionKey = value; + }, + get currentSessionId() { + return currentSessionId; + }, + set currentSessionId(value) { + currentSessionId = value; + }, + get activeChatRunId() { + return activeChatRunId; + }, + set activeChatRunId(value) { + activeChatRunId = value; + }, + get historyLoaded() { + return historyLoaded; + }, + set historyLoaded(value) { + historyLoaded = value; + }, + get sessionInfo() { + return sessionInfo; + }, + set sessionInfo(value) { + sessionInfo = value; + }, + get initialSessionApplied() { + return initialSessionApplied; + }, + set initialSessionApplied(value) { + initialSessionApplied = value; + }, + get isConnected() { + return isConnected; + }, + set isConnected(value) { + isConnected = value; + }, + get autoMessageSent() { + return autoMessageSent; + }, + set autoMessageSent(value) { + autoMessageSent = value; + }, + get toolsExpanded() { + return toolsExpanded; + }, + set toolsExpanded(value) { + toolsExpanded = value; + }, + get showThinking() { + return showThinking; + }, + set showThinking(value) { + showThinking = value; + }, + get connectionStatus() { + return connectionStatus; + }, + set connectionStatus(value) { + connectionStatus = value; + }, + get activityStatus() { + return activityStatus; + }, + set activityStatus(value) { + activityStatus = value; + }, + get statusTimeout() { + return statusTimeout; + }, + set statusTimeout(value) { + statusTimeout = value; + }, + get lastCtrlCAt() { + return lastCtrlCAt; + }, + set lastCtrlCAt(value) { + lastCtrlCAt = value; + }, + }; + const client = new GatewayChatClient({ url: opts.url, token: opts.token, @@ -354,87 +310,6 @@ export async function runTui(opts: TuiOptions) { ); }; - const 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; - }; - const closeOverlay = () => { overlay.clear(); tui.setFocus(editor); @@ -452,650 +327,59 @@ export async function runTui(opts: TuiOptions) { return parsed ? normalizeAgentId(parsed.agentId) : null; })(); - const applyAgentsResult = (result: GatewayAgentsList) => { - agentDefaultId = normalizeAgentId(result.defaultId); - sessionMainKey = normalizeMainKey(result.mainKey); - sessionScope = result.scope ?? sessionScope; - agents = result.agents.map((agent) => ({ - id: normalizeAgentId(agent.id), - name: agent.name?.trim() || undefined, - })); - agentNames.clear(); - for (const agent of agents) { - if (agent.name) agentNames.set(agent.id, agent.name); - } - if (!initialSessionApplied) { - if (initialSessionAgentId) { - if (agents.some((agent) => agent.id === initialSessionAgentId)) { - currentAgentId = initialSessionAgentId; - } - } else if (!agents.some((agent) => agent.id === currentAgentId)) { - currentAgentId = - agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId); - } - const nextSessionKey = resolveSessionKey(initialSessionInput); - if (nextSessionKey !== currentSessionKey) { - currentSessionKey = nextSessionKey; - } - initialSessionApplied = true; - } else if (!agents.some((agent) => agent.id === currentAgentId)) { - currentAgentId = - agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId); - } - updateHeader(); - updateFooter(); - }; + const sessionActions = createSessionActions({ + client, + chatLog, + tui, + opts, + state, + agentNames, + initialSessionInput, + initialSessionAgentId, + resolveSessionKey, + updateHeader, + updateFooter, + updateAutocompleteProvider, + setActivityStatus, + }); + const { + refreshAgents, + refreshSessionInfo, + loadHistory, + setSession, + abortActive, + } = sessionActions; - const refreshAgents = async () => { - try { - const result = await client.listAgents(); - applyAgentsResult(result); - } catch (err) { - chatLog.addSystem(`agents list failed: ${String(err)}`); - } - }; + const { handleChatEvent, handleAgentEvent } = createEventHandlers({ + chatLog, + tui, + state, + setActivityStatus, + }); - const updateAgentFromSessionKey = (key: string) => { - const parsed = parseAgentSessionKey(key); - if (!parsed) return; - const next = normalizeAgentId(parsed.agentId); - if (next !== currentAgentId) { - currentAgentId = next; - } - }; - - const refreshSessionInfo = async () => { - try { - const listAgentId = - currentSessionKey === "global" || currentSessionKey === "unknown" - ? undefined - : currentAgentId; - const result = await client.listSessions({ - includeGlobal: false, - includeUnknown: false, - agentId: listAgentId, - }); - const entry = result.sessions.find((row) => { - // Exact match - if (row.key === currentSessionKey) return true; - // Also match canonical keys like "agent:default:main" against "main" - const parsed = parseAgentSessionKey(row.key); - return parsed?.rest === currentSessionKey; - }); - 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: currentSessionKey, - limit: opts.historyLimit ?? 200, - }); - const record = history as { - messages?: unknown[]; - sessionId?: string; - thinkingLevel?: string; - }; - currentSessionId = - typeof record.sessionId === "string" ? record.sessionId : null; - sessionInfo.thinkingLevel = - record.thinkingLevel ?? sessionInfo.thinkingLevel; - chatLog.clearAll(); - chatLog.addSystem(`session ${currentSessionKey}`); - for (const entry of record.messages ?? []) { - if (!entry || typeof entry !== "object") continue; - const message = entry as Record; - if (message.role === "user") { - const text = extractTextFromMessage(message); - if (text) chatLog.addUser(text); - continue; - } - if (message.role === "assistant") { - const text = extractTextFromMessage(message, { - includeThinking: showThinking, - }); - if (text) chatLog.finalizeAssistant(text); - continue; - } - if (message.role === "toolResult") { - const toolCallId = 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[]) - : [], - details: - typeof message.details === "object" && message.details - ? (message.details as Record) - : undefined, - }, - { isError: Boolean(message.isError) }, - ); - } - } - 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); - currentSessionKey = nextKey; - activeChatRunId = null; - currentSessionId = null; - historyLoaded = false; - updateHeader(); - updateFooter(); - await loadHistory(); - }; - - const abortActive = async () => { - if (!activeChatRunId) { - chatLog.addSystem("no active run"); - tui.requestRender(); - return; - } - try { - await client.abortChat({ - sessionKey: currentSessionKey, - runId: activeChatRunId, - }); - setActivityStatus("aborted"); - } catch (err) { - chatLog.addSystem(`abort failed: ${String(err)}`); - setActivityStatus("abort failed"); - } - tui.requestRender(); - }; - - 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 !== 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: showThinking, - }); - if (!text) return; - chatLog.updateAssistant(text, evt.runId); - setActivityStatus("streaming"); - } - if (evt.state === "final") { - const text = extractTextFromMessage(evt.message, { - includeThinking: showThinking, - }); - const finalText = resolveFinalAssistantText({ - finalText: text, - streamedText: chatLog.getStreamingText(evt.runId), - }); - chatLog.finalizeAssistant(finalText, evt.runId); - noteFinalizedRun(evt.runId); - activeChatRunId = null; - setActivityStatus("idle"); - } - if (evt.state === "aborted") { - chatLog.addSystem("run aborted"); - activeChatRunId = null; - setActivityStatus("aborted"); - } - if (evt.state === "error") { - chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`); - activeChatRunId = null; - setActivityStatus("error"); - } - tui.requestRender(); - }; - - const handleAgentEvent = (payload: unknown) => { - if (!payload || typeof payload !== "object") return; - const evt = payload as AgentEvent; - if (!currentSessionId || evt.runId !== currentSessionId) return; - if (evt.stream === "tool") { - const data = evt.data ?? {}; - const phase = 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(); - } - }; - - const openModelSelector = async () => { - try { - const models = await client.listModels(); - if (models.length === 0) { - chatLog.addSystem("no models available"); - tui.requestRender(); - return; - } - const items = models.map((model) => ({ - value: `${model.provider}/${model.id}`, - label: `${model.provider}/${model.id}`, - description: model.name && model.name !== model.id ? model.name : "", - })); - const selector = createSelectList(items, 9); - selector.onSelect = (item) => { - void (async () => { - try { - await client.patchSession({ - key: currentSessionKey, - model: item.value, - }); - chatLog.addSystem(`model set to ${item.value}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`model set failed: ${String(err)}`); - } - closeOverlay(); - tui.requestRender(); - })(); - }; - selector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(selector); - tui.requestRender(); - } catch (err) { - chatLog.addSystem(`model list failed: ${String(err)}`); - tui.requestRender(); - } - }; - - const setAgent = async (id: string) => { - currentAgentId = normalizeAgentId(id); - await setSession(""); - }; - - const openAgentSelector = async () => { - await refreshAgents(); - if (agents.length === 0) { - chatLog.addSystem("no agents found"); - tui.requestRender(); - return; - } - const items = agents.map((agent) => ({ - value: agent.id, - label: agent.name ? `${agent.id} (${agent.name})` : agent.id, - description: agent.id === 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: 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: toolsExpanded ? "expanded" : "collapsed", - values: ["collapsed", "expanded"], - }, - { - id: "thinking", - label: "Show thinking", - currentValue: showThinking ? "on" : "off", - values: ["off", "on"], - }, - ]; - const settings = createSettingsList( - items, - (id, value) => { - if (id === "tools") { - toolsExpanded = value === "expanded"; - chatLog.setToolsExpanded(toolsExpanded); - } - if (id === "thinking") { - 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: sessionInfo.modelProvider, - model: 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: 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( - sessionInfo.modelProvider, - sessionInfo.model, - "|", - ); - chatLog.addSystem(`usage: /think <${levels}>`); - break; - } - try { - await client.patchSession({ - key: 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 "); - break; - } - try { - await client.patchSession({ - key: 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 "); - break; - } - try { - await client.patchSession({ - key: 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 "); - break; - } - const current = sessionInfo.responseUsage === "on" ? "on" : "off"; - const next = normalized ?? (current === "on" ? "off" : "on"); - try { - await client.patchSession({ - key: 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 "); - break; - } - try { - await client.patchSession({ - key: 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 "); - break; - } - try { - await client.patchSession({ - key: currentSessionKey, - groupActivation: args === "always" ? "always" : "mention", - }); - chatLog.addSystem(`activation set to ${args}`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`activation failed: ${String(err)}`); - } - break; - case "new": - case "reset": - try { - await client.resetSession(currentSessionKey); - chatLog.addSystem(`session ${currentSessionKey} reset`); - await loadHistory(); - } catch (err) { - chatLog.addSystem(`reset failed: ${String(err)}`); - } - break; - case "abort": - await abortActive(); - break; - case "settings": - openSettings(); - break; - case "exit": - case "quit": - client.stop(); - 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: currentSessionKey, - message: text, - thinking: opts.thinking, - deliver: deliverDefault, - timeoutMs: opts.timeoutMs, - }); - activeChatRunId = runId; - setActivityStatus("waiting"); - } catch (err) { - chatLog.addSystem(`send failed: ${String(err)}`); - setActivityStatus("error"); - } - tui.requestRender(); - }; + const { + handleCommand, + sendMessage, + openModelSelector, + openAgentSelector, + openSessionSelector, + } = createCommandHandlers({ + client, + chatLog, + tui, + opts, + state, + deliverDefault, + openOverlay, + closeOverlay, + refreshSessionInfo, + loadHistory, + setSession, + refreshAgents, + abortActive, + setActivityStatus, + formatSessionKey, + }); updateAutocompleteProvider(); editor.onSubmit = (text) => {