import { randomUUID } from "node:crypto"; import type { Component, SelectItem, TUI } from "@mariozechner/pi-tui"; import { formatThinkingLevels, normalizeUsageDisplay, resolveResponseUsageMode, } from "../auto-reply/thinking.js"; import type { SessionsPatchResult } from "../gateway/protocol/index.js"; import { formatRelativeTimestamp } from "../infra/format-time/format-relative.ts"; import { normalizeAgentId } from "../routing/session-key.js"; import { helpText, parseCommand } from "./commands.js"; import type { ChatLog } from "./components/chat-log.js"; import { createFilterableSelectList, createSearchableSelectList, createSettingsList, } from "./components/selectors.js"; import type { GatewayChatClient } from "./gateway-chat.js"; import { sanitizeRenderableText } from "./tui-formatters.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; applySessionInfoFromPatch: (result: SessionsPatchResult) => void; noteLocalRunId: (runId: string) => void; forgetLocalRunId?: (runId: string) => void; requestExit: () => void; }; export function createCommandHandlers(context: CommandHandlerContext) { const { client, chatLog, tui, opts, state, deliverDefault, openOverlay, closeOverlay, refreshSessionInfo, loadHistory, setSession, refreshAgents, abortActive, setActivityStatus, formatSessionKey, applySessionInfoFromPatch, noteLocalRunId, forgetLocalRunId, requestExit, } = context; const setAgent = async (id: string) => { state.currentAgentId = normalizeAgentId(id); await setSession(""); }; const closeOverlayAndRender = () => { closeOverlay(); tui.requestRender(); }; const openSelector = ( selector: { onSelect?: (item: SelectItem) => void; onCancel?: () => void; }, onSelect: (value: string) => Promise, ) => { selector.onSelect = (item) => { void (async () => { await onSelect(item.value); closeOverlayAndRender(); })(); }; selector.onCancel = closeOverlayAndRender; openOverlay(selector as Component); 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 = createSearchableSelectList(items, 9); openSelector(selector, async (value) => { try { const result = await client.patchSession({ key: state.currentSessionKey, model: value, }); chatLog.addSystem(`model set to ${value}`); applySessionInfoFromPatch(result); await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`model set failed: ${String(err)}`); } }); } 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 = createSearchableSelectList(items, 9); openSelector(selector, async (value) => { await setAgent(value); }); }; const openSessionSelector = async () => { try { const result = await client.listSessions({ includeGlobal: false, includeUnknown: false, includeDerivedTitles: true, includeLastMessage: true, agentId: state.currentAgentId, }); const items = result.sessions.map((session) => { const title = session.derivedTitle ?? session.displayName; const formattedKey = formatSessionKey(session.key); // Avoid redundant "title (key)" when title matches key const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; // Build description: time + message preview const timePart = session.updatedAt ? formatRelativeTimestamp(session.updatedAt, { dateFallback: true, fallback: "" }) : ""; const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); const description = timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart); return { value: session.key, label, description, searchText: [ session.displayName, session.label, session.subject, session.sessionId, session.key, session.lastMessagePreview, ] .filter(Boolean) .join(" "), }; }); const selector = createFilterableSelectList(items, 9); openSelector(selector, async (value) => { await setSession(value); }); } 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 { const result = await client.patchSession({ key: state.currentSessionKey, model: args, }); chatLog.addSystem(`model set to ${args}`); applySessionInfoFromPatch(result); await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`model set failed: ${String(err)}`); } } break; case "models": await openModelSelector(); break; case "think": if (!args) { const levels = formatThinkingLevels( state.sessionInfo.modelProvider, state.sessionInfo.model, "|", ); chatLog.addSystem(`usage: /think <${levels}>`); break; } try { const result = await client.patchSession({ key: state.currentSessionKey, thinkingLevel: args, }); chatLog.addSystem(`thinking set to ${args}`); applySessionInfoFromPatch(result); await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`think failed: ${String(err)}`); } break; case "verbose": if (!args) { chatLog.addSystem("usage: /verbose "); break; } try { const result = await client.patchSession({ key: state.currentSessionKey, verboseLevel: args, }); chatLog.addSystem(`verbose set to ${args}`); applySessionInfoFromPatch(result); await loadHistory(); } catch (err) { chatLog.addSystem(`verbose failed: ${String(err)}`); } break; case "reasoning": if (!args) { chatLog.addSystem("usage: /reasoning "); break; } try { const result = await client.patchSession({ key: state.currentSessionKey, reasoningLevel: args, }); chatLog.addSystem(`reasoning set to ${args}`); applySessionInfoFromPatch(result); await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`reasoning failed: ${String(err)}`); } break; case "usage": { const normalized = args ? normalizeUsageDisplay(args) : undefined; if (args && !normalized) { chatLog.addSystem("usage: /usage "); break; } const currentRaw = state.sessionInfo.responseUsage; const current = resolveResponseUsageMode(currentRaw); const next = normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); try { const result = await client.patchSession({ key: state.currentSessionKey, responseUsage: next === "off" ? null : next, }); chatLog.addSystem(`usage footer: ${next}`); applySessionInfoFromPatch(result); await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`usage failed: ${String(err)}`); } break; } case "elevated": if (!args) { chatLog.addSystem("usage: /elevated "); break; } if (!["on", "off", "ask", "full"].includes(args)) { chatLog.addSystem("usage: /elevated "); break; } try { const result = await client.patchSession({ key: state.currentSessionKey, elevatedLevel: args, }); chatLog.addSystem(`elevated set to ${args}`); applySessionInfoFromPatch(result); await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`elevated failed: ${String(err)}`); } break; case "activation": if (!args) { chatLog.addSystem("usage: /activation "); break; } try { const result = await client.patchSession({ key: state.currentSessionKey, groupActivation: args === "always" ? "always" : "mention", }); chatLog.addSystem(`activation set to ${args}`); applySessionInfoFromPatch(result); await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`activation failed: ${String(err)}`); } break; case "new": try { // Clear token counts immediately to avoid stale display (#1523) state.sessionInfo.inputTokens = null; state.sessionInfo.outputTokens = null; state.sessionInfo.totalTokens = null; tui.requestRender(); // Generate unique session key to isolate this TUI client (#39217) // This ensures /new creates a fresh session that doesn't broadcast // to other connected TUI clients sharing the original session key. const uniqueKey = `tui-${randomUUID()}`; await setSession(uniqueKey); chatLog.addSystem(`new session: ${uniqueKey}`); } catch (err) { chatLog.addSystem(`new session failed: ${sanitizeRenderableText(String(err))}`); } break; case "reset": try { // Clear token counts immediately to avoid stale display (#1523) state.sessionInfo.inputTokens = null; state.sessionInfo.outputTokens = null; state.sessionInfo.totalTokens = null; tui.requestRender(); await client.resetSession(state.currentSessionKey, name); chatLog.addSystem(`session ${state.currentSessionKey} reset`); await loadHistory(); } catch (err) { chatLog.addSystem(`reset failed: ${sanitizeRenderableText(String(err))}`); } break; case "abort": await abortActive(); break; case "settings": openSettings(); break; case "exit": case "quit": requestExit(); break; default: await sendMessage(raw); break; } tui.requestRender(); }; const sendMessage = async (text: string) => { if (!state.isConnected) { chatLog.addSystem("not connected to gateway — message not sent"); setActivityStatus("disconnected"); tui.requestRender(); return; } try { chatLog.addUser(text); tui.requestRender(); const runId = randomUUID(); noteLocalRunId(runId); state.activeChatRunId = runId; setActivityStatus("sending"); tui.requestRender(); await client.sendChat({ sessionKey: state.currentSessionKey, message: text, thinking: opts.thinking, deliver: deliverDefault, timeoutMs: opts.timeoutMs, runId, }); setActivityStatus("waiting"); tui.requestRender(); } catch (err) { if (state.activeChatRunId) { forgetLocalRunId?.(state.activeChatRunId); } state.activeChatRunId = null; chatLog.addSystem(`send failed: ${String(err)}`); setActivityStatus("error"); tui.requestRender(); } }; return { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector, openSettings, setAgent, }; }