| 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<void>; |
| loadHistory: () => Promise<void>; |
| setSession: (key: string) => Promise<void>; |
| refreshAgents: () => Promise<void>; |
| abortActive: () => Promise<void>; |
| 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<void>, |
| ) => { |
| 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); |
| |
| const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; |
| |
| 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 <on|off>"); |
| 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 "fast": |
| if (!args || args === "status") { |
| chatLog.addSystem(`fast mode: ${state.sessionInfo.fastMode ? "on" : "off"}`); |
| break; |
| } |
| if (args !== "on" && args !== "off") { |
| chatLog.addSystem("usage: /fast <status|on|off>"); |
| break; |
| } |
| try { |
| const result = await client.patchSession({ |
| key: state.currentSessionKey, |
| fastMode: args === "on", |
| }); |
| chatLog.addSystem(`fast mode ${args === "on" ? "enabled" : "disabled"}`); |
| applySessionInfoFromPatch(result); |
| await refreshSessionInfo(); |
| } catch (err) { |
| chatLog.addSystem(`fast failed: ${String(err)}`); |
| } |
| break; |
| case "reasoning": |
| if (!args) { |
| chatLog.addSystem("usage: /reasoning <on|off>"); |
| 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 <off|tokens|full>"); |
| 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 <on|off|ask|full>"); |
| break; |
| } |
| if (!["on", "off", "ask", "full"].includes(args)) { |
| chatLog.addSystem("usage: /elevated <on|off|ask|full>"); |
| 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 <mention|always>"); |
| 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 { |
| |
| state.sessionInfo.inputTokens = null; |
| state.sessionInfo.outputTokens = null; |
| state.sessionInfo.totalTokens = null; |
| tui.requestRender(); |
|
|
| |
| |
| |
| 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 { |
| |
| 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, |
| }; |
| } |
|
|