From 915837b4a7bf147fd71ce5a3291f23c74d38b518 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 17:58:00 +0200 Subject: [PATCH 1/2] TUI: split status renderer, keybindings, and connection handlers from tui.ts --- src/tui/tui-connection.ts | 121 +++++++++ src/tui/tui-keybindings.ts | 119 +++++++++ src/tui/tui-status-renderer.ts | 301 ++++++++++++++++++++++ src/tui/tui.ts | 450 ++++++--------------------------- 4 files changed, 620 insertions(+), 371 deletions(-) create mode 100644 src/tui/tui-connection.ts create mode 100644 src/tui/tui-keybindings.ts create mode 100644 src/tui/tui-status-renderer.ts diff --git a/src/tui/tui-connection.ts b/src/tui/tui-connection.ts new file mode 100644 index 0000000..5ec6fd6 --- /dev/null +++ b/src/tui/tui-connection.ts @@ -0,0 +1,121 @@ +/** + * Gateway connection handlers extracted from tui.ts. + * + * Wires the GatewayChatClient lifecycle callbacks (onEvent, onConnected, + * onDisconnected, onGap) and starts the client + TUI. The pairing/gateway-down + * hint flags are internal to this module since they are only relevant during + * connection transitions. + */ + +import type { ChatLog } from "./components/chat-log.js"; +import type { GatewayChatClient } from "./gateway-chat.js"; +import { isLoopbackHost } from "../gateway/net.js"; +import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; +import type { Component, TUI } from "@earendil-works/pi-tui"; +import { resolveGatewayDisconnectState, tryInlinePairingApproval } from "./tui-helpers.js"; + +export interface ConnectionHandlerDeps { + client: GatewayChatClient; + tui: TUI; + chatLog: ChatLog; + opts: TuiOptions; + state: TuiStateAccess; + autoMessage: string | undefined; + createWelcomeScreen: () => Component; + refreshAgents: () => Promise; + refreshSessionInfo: () => Promise; + loadHistory: () => Promise; + sendMessage: (text: string) => Promise; + updateHeader: () => void; + updateFooter: () => void; + setConnectionStatus: (text: string, ttlMs?: number) => void; + setActivityStatus: (text: string) => void; + handleChatEvent: (payload: unknown) => void; + handleAgentEvent: (payload: unknown) => void; +} + +/** + * Wires all client callbacks. Internal flags (pairingHintShown, + * gatewayDownHintShown, wasDisconnected) track hint deduplication across + * reconnect cycles. + */ +export function wireConnectionHandlers(deps: ConnectionHandlerDeps): void { + const { client, tui, chatLog, opts, state } = deps; + + let pairingHintShown = false; + let gatewayDownHintShown = false; + let wasDisconnected = false; + + client.onEvent = (evt) => { + if (evt.event === "chat") { + deps.handleChatEvent(evt.payload); + } + if (evt.event === "agent") { + deps.handleAgentEvent(evt.payload); + } + }; + + client.onConnected = () => { + state.isConnected = true; + pairingHintShown = false; + gatewayDownHintShown = false; + const reconnected = wasDisconnected; + wasDisconnected = false; + deps.setConnectionStatus("connected"); + void (async () => { + await deps.refreshAgents(); + deps.updateHeader(); + if (opts.cleanStart && !reconnected) { + chatLog.clearAll(); + chatLog.addWelcome(deps.createWelcomeScreen()); + state.historyLoaded = true; + await deps.refreshSessionInfo(); + } else { + await deps.loadHistory(); + } + deps.setConnectionStatus(reconnected ? "gateway reconnected" : "gateway connected", 4000); + tui.requestRender(); + if (!state.autoMessageSent && deps.autoMessage) { + state.autoMessageSent = true; + await deps.sendMessage(deps.autoMessage); + } + deps.updateFooter(); + tui.requestRender(); + })(); + }; + + client.onDisconnected = (reason) => { + state.isConnected = false; + wasDisconnected = true; + state.historyLoaded = false; + const disconnectState = resolveGatewayDisconnectState(reason); + deps.setConnectionStatus(disconnectState.connectionStatus, 5000); + deps.setActivityStatus(disconnectState.activityStatus); + if (disconnectState.pairingHint && !pairingHintShown) { + pairingHintShown = true; + if (isLoopbackHost(new URL(client.connection.url).hostname)) { + void tryInlinePairingApproval().then((ok) => { + if (ok) { + chatLog.addSystem("Device paired. Reconnecting..."); + } else { + chatLog.addSystem(disconnectState.pairingHint!); + } + tui.requestRender(); + }); + } else { + chatLog.addSystem(disconnectState.pairingHint); + } + } + if (disconnectState.gatewayDownHint && !gatewayDownHintShown) { + gatewayDownHintShown = true; + chatLog.addSystem(disconnectState.gatewayDownHint); + } + deps.updateFooter(); + tui.requestRender(); + }; + + client.onGap = (info) => { + deps.setConnectionStatus(`event gap: expected ${info.expected}, got ${info.received}`, 5000); + tui.requestRender(); + }; +} diff --git a/src/tui/tui-keybindings.ts b/src/tui/tui-keybindings.ts new file mode 100644 index 0000000..c68e3d0 --- /dev/null +++ b/src/tui/tui-keybindings.ts @@ -0,0 +1,119 @@ +/** + * Editor key binding wiring extracted from tui.ts. + * + * Wires the CustomEditor's onSubmit/onEscape/onCtrl-X/onShiftTab callbacks + * to their handlers. Mutable TUI state is accessed via the shared + * TuiStateAccess object so changes propagate to the rest of runTui. + */ + +import type { ChatLog } from "./components/chat-log.js"; +import type { CustomEditor } from "./components/custom-editor.js"; +import type { GatewayChatClient } from "./gateway-chat.js"; +import type { MouseHandler } from "./mouse-handler.js"; +import { nextSectionState, type SectionState, type TuiStateAccess } from "./tui-types.js"; +import type { TUI } from "@earendil-works/pi-tui"; + +export interface EditorKeyBindingDeps { + editor: CustomEditor; + tui: TUI; + chatLog: ChatLog; + client: GatewayChatClient; + state: TuiStateAccess; + mouseHandler: MouseHandler; + abortActive: () => Promise; + setActivityStatus: (text: string) => void; + loadHistory: () => Promise; + openModelSelector: () => Promise; + openAgentSelector: () => Promise; + openSessionSelector: () => Promise; +} + +/** + * Wires all editor key callbacks. The onSubmit handler must be set by the + * caller before calling this (it depends on the submit coalescer), so this + * function only wires onEscape and the Ctrl-X/ShiftTab bindings. + */ +export function wireEditorKeyBindings(deps: EditorKeyBindingDeps): void { + const { editor, tui, chatLog, client, state, mouseHandler } = deps; + + editor.onEscape = () => { + void deps.abortActive(); + }; + + editor.onCtrlC = () => { + const now = Date.now(); + // Tri-state, in priority order: + // 1. an active run → abort it + // 2. non-empty input → clear it + // 3. double Ctrl+C within 1s → exit + // Escape remains a secondary abort path. + if (state.activeChatRunId) { + void deps.abortActive(); + return; + } + if (editor.getText().trim().length > 0) { + editor.setText(""); + deps.setActivityStatus("cleared input"); + tui.requestRender(); + return; + } + if (now - state.lastCtrlCAt < 1000) { + mouseHandler.disable(); + client.stop(); + tui.stop(); + process.exit(0); + } + state.lastCtrlCAt = now; + deps.setActivityStatus("press ctrl+c again to exit"); + tui.requestRender(); + }; + + editor.onCtrlD = () => { + mouseHandler.disable(); + client.stop(); + tui.stop(); + process.exit(0); + }; + + editor.onCtrlO = () => { + const next: SectionState = nextSectionState(state.toolSectionState); + state.toolSectionState = next; + state.toolsExpanded = next === "expanded"; + chatLog.setToolSectionState(next); + deps.setActivityStatus(`tools: ${next}`); + tui.requestRender(); + }; + + editor.onCtrlL = () => { + void deps.openModelSelector(); + }; + + editor.onCtrlG = () => { + void deps.openAgentSelector(); + }; + + editor.onCtrlP = () => { + void deps.openSessionSelector(); + }; + + editor.onCtrlT = () => { + const next: SectionState = nextSectionState(state.thinkingSectionState); + state.thinkingSectionState = next; + // Thinking text is baked into the assistant message at compose time, so + // toggling requires reloading history. "hidden" suppresses thinking; + // "collapsed" and "expanded" both show it (a truncated mode would require + // restructuring the composer — deferred to a follow-up). + state.showThinking = next !== "hidden"; + void deps.loadHistory(); + }; + + editor.onShiftTab = () => { + const modes: Array<"auto" | "ask" | "deny"> = ["auto", "ask", "deny"]; + const current = state.permissionMode ?? "auto"; + const idx = modes.indexOf(current); + const nextMode = modes[(idx + 1) % modes.length] ?? "auto"; + state.permissionMode = nextMode; + deps.setActivityStatus(`permission: ${nextMode}`); + tui.requestRender(); + }; +} diff --git a/src/tui/tui-status-renderer.ts b/src/tui/tui-status-renderer.ts new file mode 100644 index 0000000..2909fcb --- /dev/null +++ b/src/tui/tui-status-renderer.ts @@ -0,0 +1,301 @@ +/** + * Status/footer renderer extracted from tui.ts. + * + * Encapsulates all status display state and rendering logic that was + * previously inlined in runTui(). The renderer owns the busy/idle timers, + * the waiting-status animation, and the connection/activity labels. + */ + +import { Container, Loader, Text, TUI } from "@earendil-works/pi-tui"; +import { formatTokens } from "./tui-formatters.js"; +import type { SessionInfo } from "./tui-types.js"; +import { theme } from "./theme/theme.js"; +import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js"; + +/** States that show a busy spinner with elapsed time. */ +const BUSY_STATES = new Set(["sending", "waiting", "streaming", "running"]); + +export interface StatusRendererDeps { + tui: TUI; + statusContainer: Container; + header: Text; + footer: Text; + gatewayUrl: string; + formatSessionKey: (key: string) => string; + formatAgentLabel: (id: string) => string; + // Lazy reads from runTui state (these change over time). + getCurrentSessionKey: () => string; + getCurrentAgentId: () => string; + getSessionInfo: () => SessionInfo; + getPermissionMode: () => "auto" | "ask" | "deny"; + getFastMode: () => boolean; +} + +export interface StatusRenderer { + /** Re-render the header line (gateway URL, agent, session). */ + updateHeader(): void; + /** Re-render the footer line (agent, session, model, tokens, flags). */ + updateFooter(): void; + /** Re-render the status area based on current activity/connection state. */ + renderStatus(): void; + /** Set the connection label, optionally with a TTL after which it reverts. */ + setConnectionStatus(text: string, ttlMs?: number): void; + /** Set the activity label and re-render the status area. */ + setActivityStatus(text: string): void; + // State delegation — the runTui `state` object proxies these. + activityStatus: string; + connectionStatus: string; + isConnected: boolean; + statusTimeout: ReturnType | null; +} + +export function createStatusRenderer(deps: StatusRendererDeps): StatusRenderer { + const { tui, statusContainer, header, footer, gatewayUrl } = deps; + + // Delegated state (read by runTui via the `state` object). + let activityStatus = "idle"; + let connectionStatus = "connecting"; + let isConnected = false; + let statusTimeout: ReturnType | null = null; + + // Internal rendering state (never accessed outside the renderer). + let statusTimer: ReturnType | null = null; + let statusStartedAt: number | null = null; + let lastActivityStatus = activityStatus; + let statusText: Text | null = null; + let statusLoader: Loader | null = null; + let waitingTick = 0; + let waitingTimer: ReturnType | null = null; + let waitingPhrase: string | null = null; + + const formatElapsed = (startMs: number) => { + const totalSeconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds}s`; + }; + + const ensureStatusText = () => { + if (statusText) { + return; + } + statusContainer.clear(); + statusLoader?.stop(); + statusLoader = null; + statusText = new Text("", 1, 0); + statusContainer.addChild(statusText); + }; + + const ensureStatusLoader = () => { + if (statusLoader) { + return; + } + statusContainer.clear(); + statusText = null; + statusLoader = new Loader( + tui, + (spinner) => theme.accent(spinner), + (text) => theme.bold(theme.accentSoft(text)), + "", + ); + statusContainer.addChild(statusLoader); + }; + + const updateBusyStatusMessage = () => { + if (!statusLoader || !statusStartedAt) { + return; + } + const elapsed = formatElapsed(statusStartedAt); + + if (activityStatus === "waiting") { + waitingTick++; + statusLoader.setMessage( + buildWaitingStatusMessage({ + theme, + tick: waitingTick, + elapsed, + connectionStatus, + phrases: waitingPhrase ? [waitingPhrase] : undefined, + }), + ); + return; + } + + statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`); + }; + + const startStatusTimer = () => { + if (statusTimer) { + return; + } + statusTimer = setInterval(() => { + if (!BUSY_STATES.has(activityStatus)) { + return; + } + updateBusyStatusMessage(); + }, 1000); + }; + + const stopStatusTimer = () => { + if (!statusTimer) { + return; + } + clearInterval(statusTimer); + statusTimer = null; + }; + + const startWaitingTimer = () => { + if (waitingTimer) { + return; + } + + // Pick a phrase once per waiting session. + if (!waitingPhrase) { + const idx = Math.floor(Math.random() * defaultWaitingPhrases.length); + waitingPhrase = defaultWaitingPhrases[idx] ?? defaultWaitingPhrases[0] ?? "waiting"; + } + + waitingTick = 0; + + waitingTimer = setInterval(() => { + if (activityStatus !== "waiting") { + return; + } + updateBusyStatusMessage(); + }, 120); + }; + + const stopWaitingTimer = () => { + if (!waitingTimer) { + return; + } + clearInterval(waitingTimer); + waitingTimer = null; + waitingPhrase = null; + }; + + const renderStatus = () => { + const isBusy = BUSY_STATES.has(activityStatus); + if (isBusy) { + if (!statusStartedAt || lastActivityStatus !== activityStatus) { + statusStartedAt = Date.now(); + } + ensureStatusLoader(); + if (activityStatus === "waiting") { + stopStatusTimer(); + startWaitingTimer(); + } else { + stopWaitingTimer(); + startStatusTimer(); + } + updateBusyStatusMessage(); + } else { + statusStartedAt = null; + stopStatusTimer(); + stopWaitingTimer(); + statusLoader?.stop(); + statusLoader = null; + ensureStatusText(); + const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus; + statusText?.setText(theme.dim(text)); + } + lastActivityStatus = activityStatus; + }; + + const setConnectionStatus = (text: string, ttlMs?: number) => { + connectionStatus = text; + renderStatus(); + if (statusTimeout) { + clearTimeout(statusTimeout); + } + if (ttlMs && ttlMs > 0) { + statusTimeout = setTimeout(() => { + connectionStatus = isConnected ? "connected" : "disconnected"; + renderStatus(); + }, ttlMs); + } + }; + + const setActivityStatus = (text: string) => { + activityStatus = text; + renderStatus(); + }; + + const updateHeader = () => { + const sessionLabel = deps.formatSessionKey(deps.getCurrentSessionKey()); + const agentLabel = deps.formatAgentLabel(deps.getCurrentAgentId()); + header.setText( + theme.header(`mayros tui - ${gatewayUrl} - agent ${agentLabel} - session ${sessionLabel}`), + ); + }; + + const updateFooter = () => { + const sessionKeyLabel = deps.formatSessionKey(deps.getCurrentSessionKey()); + const sessionInfo = deps.getSessionInfo(); + const sessionLabel = sessionInfo.displayName + ? `${sessionKeyLabel} (${sessionInfo.displayName})` + : sessionKeyLabel; + const agentLabel = deps.formatAgentLabel(deps.getCurrentAgentId()); + const modelLabel = sessionInfo.model + ? sessionInfo.modelProvider + ? `${sessionInfo.modelProvider}/${sessionInfo.model}` + : sessionInfo.model + : "unknown"; + const tokens = formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null); + const think = sessionInfo.thinkingLevel ?? "off"; + const verbose = sessionInfo.verboseLevel ?? "off"; + const reasoning = sessionInfo.reasoningLevel ?? "off"; + const reasoningLabel = + reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null; + const permissionMode = deps.getPermissionMode(); + const permLabel = permissionMode !== "auto" ? `perm ${permissionMode}` : null; + const fastLabel = deps.getFastMode() ? "FAST" : null; + const footerParts = [ + `agent ${agentLabel}`, + `session ${sessionLabel}`, + modelLabel, + think !== "off" ? `think ${think}` : null, + verbose !== "off" ? `verbose ${verbose}` : null, + reasoningLabel, + tokens, + permLabel, + fastLabel, + ].filter(Boolean); + footer.setText(theme.dim(footerParts.join(" | "))); + }; + + return { + updateHeader, + updateFooter, + renderStatus, + setConnectionStatus, + setActivityStatus, + get activityStatus() { + return activityStatus; + }, + set activityStatus(value: string) { + activityStatus = value; + }, + get connectionStatus() { + return connectionStatus; + }, + set connectionStatus(value: string) { + connectionStatus = value; + }, + get isConnected() { + return isConnected; + }, + set isConnected(value: boolean) { + isConnected = value; + }, + get statusTimeout() { + return statusTimeout; + }, + set statusTimeout(value: ReturnType | null) { + statusTimeout = value; + }, + }; +} diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 5718f8b..9a4beb2 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1,7 +1,6 @@ import { CombinedAutocompleteProvider, Container, - Loader, ProcessTerminal, Text, TUI, @@ -9,7 +8,6 @@ import { import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { VERSION } from "../version.js"; import { loadConfig } from "../config/config.js"; -import { isLoopbackHost } from "../gateway/net.js"; import { normalizeAgentId, normalizeMainKey, @@ -32,10 +30,12 @@ import { editorTheme, theme, setThemePreset } from "./theme/theme.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; import { MouseHandler, createMouseInputListener } from "./mouse-handler.js"; -import { formatTokens } from "./tui-formatters.js"; import { createLocalShellRunner } from "./tui-local-shell.js"; import { createOverlayHandlers } from "./tui-overlays.js"; import { createSessionActions } from "./tui-session-actions.js"; +import { createStatusRenderer, type StatusRenderer } from "./tui-status-renderer.js"; +import { wireEditorKeyBindings } from "./tui-keybindings.js"; +import { wireConnectionHandlers } from "./tui-connection.js"; import type { AgentSummary, PendingImage, @@ -45,8 +45,6 @@ import type { TuiOptions, TuiStateAccess, } from "./tui-types.js"; -import { nextSectionState } from "./tui-types.js"; -import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js"; export { resolveFinalAssistantText } from "./tui-formatters.js"; export type { TuiOptions } from "./tui-types.js"; @@ -63,9 +61,7 @@ import { shouldEnableWindowsGitBashPasteFallback, createSubmitBurstCoalescer, resolveTuiSessionKey, - resolveGatewayDisconnectState, createBackspaceDeduper, - tryInlinePairingApproval, } from "./tui-helpers.js"; export async function runTui(opts: TuiOptions) { @@ -109,14 +105,10 @@ export async function runTui(opts: TuiOptions) { let currentSessionId: string | null = null; let activeChatRunId: string | null = null; let historyLoaded = false; - let isConnected = false; - let wasDisconnected = false; let toolsExpanded = false; let showThinking = false; let toolSectionState: SectionState = "collapsed"; let thinkingSectionState: SectionState = "collapsed"; - let pairingHintShown = false; - let gatewayDownHintShown = false; let outputStyle: string | undefined; let permissionMode: "auto" | "ask" | "deny" = "auto"; let fastMode = false; @@ -135,12 +127,10 @@ export async function runTui(opts: TuiOptions) { let autoMessageSent = false; let sessionInfo: SessionInfo = {}; let lastCtrlCAt = 0; - let activityStatus = "idle"; - let connectionStatus = "connecting"; - let statusTimeout: NodeJS.Timeout | null = null; - let statusTimer: NodeJS.Timeout | null = null; - let statusStartedAt: number | null = null; - let lastActivityStatus = activityStatus; + // Status display state (activity/connection labels + timers) is owned by the + // status renderer created below. The `state` object delegates these fields + // to the renderer via late-bound references. + let statusRenderer: StatusRenderer | null = null; const state: TuiStateAccess = { get agentDefaultId() { @@ -210,10 +200,12 @@ export async function runTui(opts: TuiOptions) { initialSessionApplied = value; }, get isConnected() { - return isConnected; + return statusRenderer?.isConnected ?? false; }, set isConnected(value) { - isConnected = value; + if (statusRenderer) { + statusRenderer.isConnected = value; + } }, get autoMessageSent() { return autoMessageSent; @@ -246,22 +238,28 @@ export async function runTui(opts: TuiOptions) { thinkingSectionState = value; }, get connectionStatus() { - return connectionStatus; + return statusRenderer?.connectionStatus ?? "connecting"; }, set connectionStatus(value) { - connectionStatus = value; + if (statusRenderer) { + statusRenderer.connectionStatus = value; + } }, get activityStatus() { - return activityStatus; + return statusRenderer?.activityStatus ?? "idle"; }, set activityStatus(value) { - activityStatus = value; + if (statusRenderer) { + statusRenderer.activityStatus = value; + } }, get statusTimeout() { - return statusTimeout; + return statusRenderer?.statusTimeout ?? null; }, set statusTimeout(value) { - statusTimeout = value; + if (statusRenderer) { + statusRenderer.statusTimeout = value; + } }, get lastCtrlCAt() { return lastCtrlCAt; @@ -421,212 +419,30 @@ export async function runTui(opts: TuiOptions) { currentSessionKey = resolveSessionKey(initialSessionInput); - const updateHeader = () => { - const sessionLabel = formatSessionKey(currentSessionKey); - const agentLabel = formatAgentLabel(currentAgentId); - header.setText( - theme.header( - `mayros tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`, - ), - ); - }; - - const busyStates = new Set(["sending", "waiting", "streaming", "running"]); - let statusText: Text | null = null; - let statusLoader: Loader | null = null; - - const formatElapsed = (startMs: number) => { - const totalSeconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); - if (totalSeconds < 60) { - return `${totalSeconds}s`; - } - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}m ${seconds}s`; - }; - - const ensureStatusText = () => { - if (statusText) { - return; - } - statusContainer.clear(); - statusLoader?.stop(); - statusLoader = null; - statusText = new Text("", 1, 0); - statusContainer.addChild(statusText); - }; - - const ensureStatusLoader = () => { - if (statusLoader) { - return; - } - statusContainer.clear(); - statusText = null; - statusLoader = new Loader( - tui, - (spinner) => theme.accent(spinner), - (text) => theme.bold(theme.accentSoft(text)), - "", - ); - statusContainer.addChild(statusLoader); - }; - - let waitingTick = 0; - let waitingTimer: NodeJS.Timeout | null = null; - let waitingPhrase: string | null = null; - - const updateBusyStatusMessage = () => { - if (!statusLoader || !statusStartedAt) { - return; - } - const elapsed = formatElapsed(statusStartedAt); - - if (activityStatus === "waiting") { - waitingTick++; - statusLoader.setMessage( - buildWaitingStatusMessage({ - theme, - tick: waitingTick, - elapsed, - connectionStatus, - phrases: waitingPhrase ? [waitingPhrase] : undefined, - }), - ); - return; - } - - statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`); - }; - - const startStatusTimer = () => { - if (statusTimer) { - return; - } - statusTimer = setInterval(() => { - if (!busyStates.has(activityStatus)) { - return; - } - updateBusyStatusMessage(); - }, 1000); - }; - - const stopStatusTimer = () => { - if (!statusTimer) { - return; - } - clearInterval(statusTimer); - statusTimer = null; - }; - - const startWaitingTimer = () => { - if (waitingTimer) { - return; - } - - // Pick a phrase once per waiting session. - if (!waitingPhrase) { - const idx = Math.floor(Math.random() * defaultWaitingPhrases.length); - waitingPhrase = defaultWaitingPhrases[idx] ?? defaultWaitingPhrases[0] ?? "waiting"; - } - - waitingTick = 0; - - waitingTimer = setInterval(() => { - if (activityStatus !== "waiting") { - return; - } - updateBusyStatusMessage(); - }, 120); - }; - - const stopWaitingTimer = () => { - if (!waitingTimer) { - return; - } - clearInterval(waitingTimer); - waitingTimer = null; - waitingPhrase = null; - }; - - const renderStatus = () => { - const isBusy = busyStates.has(activityStatus); - if (isBusy) { - if (!statusStartedAt || lastActivityStatus !== activityStatus) { - statusStartedAt = Date.now(); - } - ensureStatusLoader(); - if (activityStatus === "waiting") { - stopStatusTimer(); - startWaitingTimer(); - } else { - stopWaitingTimer(); - startStatusTimer(); - } - updateBusyStatusMessage(); - } else { - statusStartedAt = null; - stopStatusTimer(); - stopWaitingTimer(); - statusLoader?.stop(); - statusLoader = null; - ensureStatusText(); - const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus; - statusText?.setText(theme.dim(text)); - } - lastActivityStatus = activityStatus; - }; - - const setConnectionStatus = (text: string, ttlMs?: number) => { - connectionStatus = text; - renderStatus(); - if (statusTimeout) { - clearTimeout(statusTimeout); - } - if (ttlMs && ttlMs > 0) { - statusTimeout = setTimeout(() => { - connectionStatus = isConnected ? "connected" : "disconnected"; - renderStatus(); - }, ttlMs); - } - }; - - const setActivityStatus = (text: string) => { - activityStatus = text; - renderStatus(); - }; + // Status renderer owns the connection/activity labels, busy/idle timers, + // and the waiting-status animation. Created here so it can capture the + // header/footer/statusContainer nodes; the `state` object above delegates + // activityStatus/connectionStatus/isConnected/statusTimeout to it. + statusRenderer = createStatusRenderer({ + tui, + statusContainer, + header, + footer, + gatewayUrl: client.connection.url, + formatSessionKey, + formatAgentLabel, + getCurrentSessionKey: () => currentSessionKey, + getCurrentAgentId: () => currentAgentId, + getSessionInfo: () => sessionInfo, + getPermissionMode: () => permissionMode, + getFastMode: () => fastMode, + }); - const updateFooter = () => { - const sessionKeyLabel = formatSessionKey(currentSessionKey); - const sessionLabel = sessionInfo.displayName - ? `${sessionKeyLabel} (${sessionInfo.displayName})` - : sessionKeyLabel; - const agentLabel = formatAgentLabel(currentAgentId); - const modelLabel = sessionInfo.model - ? sessionInfo.modelProvider - ? `${sessionInfo.modelProvider}/${sessionInfo.model}` - : sessionInfo.model - : "unknown"; - const tokens = formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null); - const think = sessionInfo.thinkingLevel ?? "off"; - const verbose = sessionInfo.verboseLevel ?? "off"; - const reasoning = sessionInfo.reasoningLevel ?? "off"; - const reasoningLabel = - reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null; - const permLabel = permissionMode !== "auto" ? `perm ${permissionMode}` : null; - const fastLabel = fastMode ? "FAST" : null; - const footerParts = [ - `agent ${agentLabel}`, - `session ${sessionLabel}`, - modelLabel, - think !== "off" ? `think ${think}` : null, - verbose !== "off" ? `verbose ${verbose}` : null, - reasoningLabel, - tokens, - permLabel, - fastLabel, - ].filter(Boolean); - footer.setText(theme.dim(footerParts.join(" | "))); - }; + const updateHeader = () => statusRenderer.updateHeader(); + const updateFooter = () => statusRenderer.updateFooter(); + const setConnectionStatus = (text: string, ttlMs?: number) => + statusRenderer.setConnectionStatus(text, ttlMs); + const setActivityStatus = (text: string) => statusRenderer.setActivityStatus(text); const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor); @@ -719,148 +535,40 @@ export async function runTui(opts: TuiOptions) { enabled: shouldEnableWindowsGitBashPasteFallback(), }); - editor.onEscape = () => { - void abortActive(); - }; - editor.onCtrlC = () => { - const now = Date.now(); - // Tri-state, in priority order: - // 1. an active run → abort it - // 2. non-empty input → clear it - // 3. double Ctrl+C within 1s → exit - // Escape remains a secondary abort path. - if (activeChatRunId) { - void abortActive(); - return; - } - if (editor.getText().trim().length > 0) { - editor.setText(""); - setActivityStatus("cleared input"); - tui.requestRender(); - return; - } - if (now - lastCtrlCAt < 1000) { - mouseHandler.disable(); - client.stop(); - tui.stop(); - process.exit(0); - } - lastCtrlCAt = now; - setActivityStatus("press ctrl+c again to exit"); - tui.requestRender(); - }; - editor.onCtrlD = () => { - mouseHandler.disable(); - client.stop(); - tui.stop(); - process.exit(0); - }; - editor.onCtrlO = () => { - toolSectionState = nextSectionState(toolSectionState); - toolsExpanded = toolSectionState === "expanded"; - chatLog.setToolSectionState(toolSectionState); - setActivityStatus(`tools: ${toolSectionState}`); - tui.requestRender(); - }; - editor.onCtrlL = () => { - void openModelSelector(); - }; - editor.onCtrlG = () => { - void openAgentSelector(); - }; - editor.onCtrlP = () => { - void openSessionSelector(); - }; - editor.onCtrlT = () => { - thinkingSectionState = nextSectionState(thinkingSectionState); - // Thinking text is baked into the assistant message at compose time, so - // toggling requires reloading history. "hidden" suppresses thinking; - // "collapsed" and "expanded" both show it (a truncated mode would require - // restructuring the composer — deferred to a follow-up). - showThinking = thinkingSectionState !== "hidden"; - void loadHistory(); - }; - editor.onShiftTab = () => { - const modes: Array<"auto" | "ask" | "deny"> = ["auto", "ask", "deny"]; - const idx = modes.indexOf(permissionMode); - permissionMode = modes[(idx + 1) % modes.length] ?? "auto"; - state.permissionMode = permissionMode; - setActivityStatus(`permission: ${permissionMode}`); - tui.requestRender(); - }; - - client.onEvent = (evt) => { - if (evt.event === "chat") { - handleChatEvent(evt.payload); - } - if (evt.event === "agent") { - handleAgentEvent(evt.payload); - } - }; - - client.onConnected = () => { - isConnected = true; - pairingHintShown = false; - gatewayDownHintShown = false; - const reconnected = wasDisconnected; - wasDisconnected = false; - setConnectionStatus("connected"); - void (async () => { - await refreshAgents(); - updateHeader(); - if (opts.cleanStart && !reconnected) { - chatLog.clearAll(); - chatLog.addWelcome(createWelcomeScreen()); - historyLoaded = true; - await refreshSessionInfo(); - } else { - await loadHistory(); - } - setConnectionStatus(reconnected ? "gateway reconnected" : "gateway connected", 4000); - tui.requestRender(); - if (!autoMessageSent && autoMessage) { - autoMessageSent = true; - await sendMessage(autoMessage); - } - updateFooter(); - tui.requestRender(); - })(); - }; - - client.onDisconnected = (reason) => { - isConnected = false; - wasDisconnected = true; - historyLoaded = false; - const disconnectState = resolveGatewayDisconnectState(reason); - setConnectionStatus(disconnectState.connectionStatus, 5000); - setActivityStatus(disconnectState.activityStatus); - if (disconnectState.pairingHint && !pairingHintShown) { - pairingHintShown = true; - if (isLoopbackHost(new URL(client.connection.url).hostname)) { - void tryInlinePairingApproval().then((ok) => { - if (ok) { - chatLog.addSystem("Device paired. Reconnecting..."); - } else { - chatLog.addSystem(disconnectState.pairingHint!); - } - tui.requestRender(); - }); - } else { - chatLog.addSystem(disconnectState.pairingHint); - } - } - if (disconnectState.gatewayDownHint && !gatewayDownHintShown) { - gatewayDownHintShown = true; - chatLog.addSystem(disconnectState.gatewayDownHint); - } - updateFooter(); - tui.requestRender(); - }; + wireEditorKeyBindings({ + editor, + tui, + chatLog, + client, + state, + mouseHandler, + abortActive, + setActivityStatus, + loadHistory, + openModelSelector, + openAgentSelector, + openSessionSelector, + }); - client.onGap = (info) => { - setConnectionStatus(`event gap: expected ${info.expected}, got ${info.received}`, 5000); - tui.requestRender(); - }; + wireConnectionHandlers({ + client, + tui, + chatLog, + opts, + state, + autoMessage, + createWelcomeScreen, + refreshAgents, + refreshSessionInfo, + loadHistory, + sendMessage, + updateHeader, + updateFooter, + setConnectionStatus, + setActivityStatus, + handleChatEvent, + handleAgentEvent, + }); updateHeader(); setConnectionStatus("connecting"); From b178a78cdbc34a62e00d8ef765ddb44d0a09775d Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 18:10:51 +0200 Subject: [PATCH 2/2] TUI: split command handlers into per-group modules with dispatcher --- src/tui/tui-cmd-clipboard.ts | 48 ++ src/tui/tui-cmd-config.ts | 308 ++++++++ src/tui/tui-cmd-display.ts | 184 +++++ src/tui/tui-cmd-ecosystem.ts | 129 ++++ src/tui/tui-cmd-misc.ts | 251 +++++++ src/tui/tui-cmd-session.ts | 153 ++++ src/tui/tui-cmd-teleport.ts | 131 ++++ src/tui/tui-cmd-types.ts | 59 ++ src/tui/tui-command-handlers.ts | 1173 ++----------------------------- 9 files changed, 1324 insertions(+), 1112 deletions(-) create mode 100644 src/tui/tui-cmd-clipboard.ts create mode 100644 src/tui/tui-cmd-config.ts create mode 100644 src/tui/tui-cmd-display.ts create mode 100644 src/tui/tui-cmd-ecosystem.ts create mode 100644 src/tui/tui-cmd-misc.ts create mode 100644 src/tui/tui-cmd-session.ts create mode 100644 src/tui/tui-cmd-teleport.ts create mode 100644 src/tui/tui-cmd-types.ts diff --git a/src/tui/tui-cmd-clipboard.ts b/src/tui/tui-cmd-clipboard.ts new file mode 100644 index 0000000..cbc0e22 --- /dev/null +++ b/src/tui/tui-cmd-clipboard.ts @@ -0,0 +1,48 @@ +/** + * Clipboard/export command group. + * Commands: copy, export + */ + +import { writeFileSync } from "node:fs"; +import { copyToClipboard } from "../infra/clipboard.js"; +import type { CommandGroupHandler } from "./tui-cmd-types.js"; + +export const clipboardCommands: CommandGroupHandler = async (ctx, name, args, _raw) => { + const { chatLog } = ctx; + + switch (name) { + case "copy": { + const lastText = chatLog.getLastAssistantText(); + if (!lastText) { + chatLog.addSystem("nothing to copy"); + return true; + } + try { + const ok = await copyToClipboard(lastText); + chatLog.addSystem( + ok ? "last response copied to clipboard" : "copy failed: no clipboard tool found", + ); + } catch (err) { + chatLog.addSystem(`copy failed: ${String(err)}`); + } + return true; + } + case "export": { + const lastText = chatLog.getLastAssistantText(); + if (!lastText) { + chatLog.addSystem("nothing to export"); + return true; + } + const filePath = args || `mayros-export-${Date.now()}.md`; + try { + writeFileSync(filePath, lastText, "utf-8"); + chatLog.addSystem(`exported to ${filePath}`); + } catch (err) { + chatLog.addSystem(`export failed: ${String(err)}`); + } + return true; + } + default: + return false; + } +}; diff --git a/src/tui/tui-cmd-config.ts b/src/tui/tui-cmd-config.ts new file mode 100644 index 0000000..1f1ef3d --- /dev/null +++ b/src/tui/tui-cmd-config.ts @@ -0,0 +1,308 @@ +/** + * Configuration command group. + * Commands: think, verbose, reasoning, elevated, activation, usage, context + */ + +import { + listThinkingLevelLabels, + normalizeUsageDisplay, + resolveResponseUsageMode, +} from "../auto-reply/thinking.js"; +import { formatContextVisualization } from "./context-visualizer.js"; +import { createSelectList } from "./components/selectors.js"; +import type { CommandGroupHandler } from "./tui-cmd-types.js"; + +export const configCommands: CommandGroupHandler = async (ctx, name, args, _raw) => { + const { + client, + chatLog, + tui, + state, + refreshSessionInfo, + loadHistory, + applySessionInfoFromPatch, + } = ctx; + + switch (name) { + case "think": + if (!args) { + const levels = listThinkingLevelLabels( + state.sessionInfo.modelProvider, + state.sessionInfo.model, + ); + const currentThink = state.sessionInfo.thinkingLevel ?? "medium"; + const thinkItems = levels.map((l) => ({ + value: l, + label: l === currentThink ? `${l} (current)` : l, + })); + const thinkSelector = createSelectList(thinkItems, thinkItems.length); + thinkSelector.onSelect = (item) => { + void (async () => { + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + thinkingLevel: item.value, + }); + chatLog.addSystem(`thinking set to ${item.value}`); + applySessionInfoFromPatch(result); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`think failed: ${String(err)}`); + } + ctx.closeOverlay(); + tui.requestRender(); + })(); + }; + thinkSelector.onCancel = () => { + ctx.closeOverlay(); + tui.requestRender(); + }; + ctx.openOverlay(thinkSelector); + tui.requestRender(); + return true; + } + 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)}`); + } + return true; + case "verbose": { + if (!args) { + const verboseOpts = ["on", "off"]; + const currentVerbose = state.sessionInfo.verboseLevel ?? "off"; + const verboseItems = verboseOpts.map((v) => ({ + value: v, + label: v === currentVerbose ? `${v} (current)` : v, + })); + const verboseSelector = createSelectList(verboseItems, verboseItems.length); + verboseSelector.onSelect = (item) => { + void (async () => { + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + verboseLevel: item.value, + }); + chatLog.addSystem(`verbose set to ${item.value}`); + applySessionInfoFromPatch(result); + await loadHistory(); + } catch (err) { + chatLog.addSystem(`verbose failed: ${String(err)}`); + } + ctx.closeOverlay(); + tui.requestRender(); + })(); + }; + verboseSelector.onCancel = () => { + ctx.closeOverlay(); + tui.requestRender(); + }; + ctx.openOverlay(verboseSelector); + tui.requestRender(); + return true; + } + 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)}`); + } + return true; + } + case "reasoning": { + if (!args) { + const reasoningOpts = ["on", "off"]; + const currentReasoning = state.sessionInfo.reasoningLevel ?? "off"; + const reasoningItems = reasoningOpts.map((r) => ({ + value: r, + label: r === currentReasoning ? `${r} (current)` : r, + })); + const reasoningSelector = createSelectList(reasoningItems, reasoningItems.length); + reasoningSelector.onSelect = (item) => { + void (async () => { + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + reasoningLevel: item.value, + }); + chatLog.addSystem(`reasoning set to ${item.value}`); + applySessionInfoFromPatch(result); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`reasoning failed: ${String(err)}`); + } + ctx.closeOverlay(); + tui.requestRender(); + })(); + }; + reasoningSelector.onCancel = () => { + ctx.closeOverlay(); + tui.requestRender(); + }; + ctx.openOverlay(reasoningSelector); + tui.requestRender(); + return true; + } + 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)}`); + } + return true; + } + case "usage": { + const normalized = args ? normalizeUsageDisplay(args) : undefined; + if (args && !normalized) { + chatLog.addSystem("usage: /usage "); + return true; + } + 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)}`); + } + return true; + } + case "elevated": { + if (!args) { + const elevatedOpts = ["on", "off", "ask", "full"]; + const currentElevated = state.sessionInfo.elevatedLevel ?? "off"; + const elevatedItems = elevatedOpts.map((e) => ({ + value: e, + label: e === currentElevated ? `${e} (current)` : e, + })); + const elevatedSelector = createSelectList(elevatedItems, elevatedItems.length); + elevatedSelector.onSelect = (item) => { + void (async () => { + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + elevatedLevel: item.value, + }); + chatLog.addSystem(`elevated set to ${item.value}`); + applySessionInfoFromPatch(result); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`elevated failed: ${String(err)}`); + } + ctx.closeOverlay(); + tui.requestRender(); + })(); + }; + elevatedSelector.onCancel = () => { + ctx.closeOverlay(); + tui.requestRender(); + }; + ctx.openOverlay(elevatedSelector); + tui.requestRender(); + return true; + } + if (!["on", "off", "ask", "full"].includes(args)) { + chatLog.addSystem("usage: /elevated "); + return true; + } + 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)}`); + } + return true; + } + case "activation": { + if (!args) { + const activationOpts = ["mention", "always"]; + const currentActivation = state.sessionInfo.groupActivation ?? "mention"; + const activationItems = activationOpts.map((a) => ({ + value: a, + label: a === currentActivation ? `${a} (current)` : a, + })); + const activationSelector = createSelectList(activationItems, activationItems.length); + activationSelector.onSelect = (item) => { + void (async () => { + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + groupActivation: item.value === "always" ? "always" : "mention", + }); + chatLog.addSystem(`activation set to ${item.value}`); + applySessionInfoFromPatch(result); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`activation failed: ${String(err)}`); + } + ctx.closeOverlay(); + tui.requestRender(); + })(); + }; + activationSelector.onCancel = () => { + ctx.closeOverlay(); + tui.requestRender(); + }; + ctx.openOverlay(activationSelector); + tui.requestRender(); + return true; + } + 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)}`); + } + return true; + } + case "context": { + const used = state.sessionInfo.totalTokens ?? 0; + const max = state.sessionInfo.contextTokens ?? 0; + const lines = formatContextVisualization({ + usedTokens: used, + maxTokens: max, + inputTokens: state.sessionInfo.inputTokens, + outputTokens: state.sessionInfo.outputTokens, + }); + for (const line of lines) { + chatLog.addSystem(line); + } + return true; + } + default: + return false; + } +}; diff --git a/src/tui/tui-cmd-display.ts b/src/tui/tui-cmd-display.ts new file mode 100644 index 0000000..395920d --- /dev/null +++ b/src/tui/tui-cmd-display.ts @@ -0,0 +1,184 @@ +/** + * Display/style command group. + * Commands: style, theme, diff, permission, fast, tools + */ + +import { execSync } from "node:child_process"; +import { renderDiff, renderDiffStats } from "./diff-renderer.js"; +import { theme } from "./theme/theme.js"; +import { isValidOutputStyle, OUTPUT_STYLE_NAMES } from "./output-styles.js"; +import type { OutputStyle } from "./output-styles.js"; +import { THEME_PRESETS, listCustomThemeNames } from "./theme/palettes.js"; +import type { BuiltinPreset } from "./theme/palettes.js"; +import { setThemePreset, getThemePreset } from "./theme/theme.js"; +import { createSelectList } from "./components/selectors.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { CommandGroupHandler } from "./tui-cmd-types.js"; + +const logger = createSubsystemLogger("tui"); + +export const displayCommands: CommandGroupHandler = async (ctx, name, args, _raw) => { + const { client, chatLog, tui, state, applySessionInfoFromPatch } = ctx; + + switch (name) { + case "diff": { + try { + const cmd = args ? `git diff -- ${args}` : "git diff"; + const raw = execSync(cmd, { encoding: "utf-8", maxBuffer: 1024 * 1024 }).trim(); + if (!raw) { + chatLog.addSystem("no changes"); + return true; + } + const stats = renderDiffStats(raw); + chatLog.addSystem( + `${stats.files} file(s) changed, +${stats.additions} -${stats.deletions}`, + ); + for (const line of renderDiff(raw, { + add: theme.success, + del: theme.error, + header: (t) => theme.bold(t), + hunk: theme.accent, + context: theme.dim, + })) { + chatLog.addSystem(line); + } + } catch (err) { + chatLog.addSystem(`diff failed: ${String(err)}`); + } + return true; + } + case "style": { + const styleName = args.toLowerCase(); + if (!styleName) { + const currentStyle = state.outputStyle ?? "standard"; + const styleItems = OUTPUT_STYLE_NAMES.map((s) => ({ + value: s, + label: s === currentStyle ? `${s} (current)` : s, + })); + const styleSelector = createSelectList(styleItems, styleItems.length); + styleSelector.onSelect = (item) => { + state.outputStyle = item.value as OutputStyle; + chatLog.addSystem(`output style set to ${item.value}`); + ctx.closeOverlay(); + tui.requestRender(); + }; + styleSelector.onCancel = () => { + ctx.closeOverlay(); + tui.requestRender(); + }; + ctx.openOverlay(styleSelector); + tui.requestRender(); + return true; + } + if (!isValidOutputStyle(styleName)) { + chatLog.addSystem(`unknown style. usage: /style <${OUTPUT_STYLE_NAMES.join("|")}>`); + return true; + } + state.outputStyle = styleName; + chatLog.addSystem(`output style set to ${styleName}`); + return true; + } + case "theme": { + const preset = args.toLowerCase(); + if (!preset) { + const currentTheme = getThemePreset(); + const themeItems = THEME_PRESETS.map((t) => ({ + value: t, + label: t === currentTheme ? `${t} (current)` : t, + })); + const themeSelector = createSelectList(themeItems, themeItems.length); + themeSelector.onSelect = (item) => { + setThemePreset(item.value); + chatLog.addSystem(`theme set to ${item.value}`); + ctx.closeOverlay(); + tui.requestRender(); + }; + themeSelector.onCancel = () => { + ctx.closeOverlay(); + tui.requestRender(); + }; + ctx.openOverlay(themeSelector); + tui.requestRender(); + return true; + } + if ( + !THEME_PRESETS.includes(preset as BuiltinPreset) && + !listCustomThemeNames().includes(preset) + ) { + chatLog.addSystem( + `unknown theme. usage: /theme <${THEME_PRESETS.join("|")}> or a custom theme name`, + ); + return true; + } + setThemePreset(preset); + chatLog.addSystem(`theme set to ${preset}`); + return true; + } + case "permission": { + const MODES = ["auto", "ask", "deny"] as const; + type PermMode = (typeof MODES)[number]; + const mode = args.toLowerCase(); + if (!mode) { + const current = state.permissionMode ?? "auto"; + const idx = MODES.indexOf(current); + const next = MODES[(idx + 1) % MODES.length] as PermMode; + state.permissionMode = next; + chatLog.addSystem(`permission mode: ${next}`); + } else if (MODES.includes(mode as PermMode)) { + state.permissionMode = mode as PermMode; + chatLog.addSystem(`permission mode set to ${mode}`); + } else { + chatLog.addSystem("usage: /permission "); + } + return true; + } + case "fast": { + const isFast = !state.fastMode; + state.fastMode = isFast; + if (isFast) { + // Save current thinking level before switching + state.previousThinkingLevel = state.sessionInfo.thinkingLevel ?? "medium"; + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + thinkingLevel: "off", + }); + applySessionInfoFromPatch(result); + } catch (err) { + // Best-effort — fast mode works locally even without gateway + logger.warn("fast-mode: failed to set thinking off", { error: String(err) }); + } + state.outputStyle = "standard"; + chatLog.addSystem("fast mode enabled (thinking: off, style: standard)"); + } else { + // Restore previous thinking level + const prevLevel = state.previousThinkingLevel ?? "medium"; + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + thinkingLevel: prevLevel, + }); + applySessionInfoFromPatch(result); + } catch (err) { + // Best-effort — restore may fail if gateway is unreachable + logger.warn("fast-mode: failed to restore thinking", { + level: prevLevel, + error: String(err), + }); + } + chatLog.addSystem(`fast mode disabled (thinking: ${prevLevel})`); + } + return true; + } + case "tools": { + await ctx.sendMessage( + "List every tool name you have access to. Output ONLY a numbered list of tool names, nothing else. " + + "Do NOT describe them. Just the names, one per line.", + "/tools", + ); + return true; + } + default: + return false; + } +}; diff --git a/src/tui/tui-cmd-ecosystem.ts b/src/tui/tui-cmd-ecosystem.ts new file mode 100644 index 0000000..18de0bc --- /dev/null +++ b/src/tui/tui-cmd-ecosystem.ts @@ -0,0 +1,129 @@ +/** + * Mayros ecosystem command group. + * Commands: plan, kg, trace, team, tasks, workflow, rules, mailbox, sync, mouse + */ + +import type { CommandGroupHandler } from "./tui-cmd-types.js"; + +export const ecosystemCommands: CommandGroupHandler = async (ctx, name, args, _raw) => { + const { chatLog } = ctx; + + switch (name) { + case "plan": { + const action = args || "show"; + await ctx.sendMessage(`/plan ${action}`); + return true; + } + case "mouse": { + if (ctx.mouseHandler) { + if (ctx.mouseHandler.isEnabled()) { + ctx.mouseHandler.disable(); + chatLog.addSystem("Mouse reporting disabled — text selection enabled."); + } else { + ctx.mouseHandler.enable(); + chatLog.addSystem("Mouse reporting enabled."); + } + } else { + chatLog.addSystem("Mouse handler not available."); + } + return true; + } + case "kg": { + // Check if semantic memory tools are likely available by asking the gateway + const kgHint = + "You MUST use one of these tools (in order of preference): " + + "memory_stats, semantic_memory_query, semantic_memory_recall. " + + "If none of these tools are available to you, respond EXACTLY with: " + + '"[kg] Plugin memory-semantic is not loaded. Run `mayros plugins list` to check."'; + if (!args) { + await ctx.sendMessage( + `Show a knowledge graph summary. ${kgHint} Show categories, triple counts, and recent entries.`, + "/kg", + ); + } else { + await ctx.sendMessage(`Search the knowledge graph for: ${args}. ${kgHint}`, `/kg ${args}`); + } + return true; + } + case "trace": { + const subCmd = args || "events"; + if (subCmd === "stats") { + await ctx.sendMessage( + "Use the trace_stats tool with no arguments to show aggregated observability statistics for the current agent.", + "/trace stats", + ); + } else if (subCmd === "explain" && args.includes(" ")) { + const eventId = args.slice("explain".length).trim(); + await ctx.sendMessage( + `Use the trace_explain tool with eventId "${eventId}" to trace the causal chain for that event.`, + `/trace explain ${eventId}`, + ); + } else { + await ctx.sendMessage( + "Use the trace_query tool with no arguments to list recent trace events for the current agent.", + "/trace events", + ); + } + return true; + } + case "team": { + await ctx.sendMessage( + "Use the mesh_team_dashboard tool with no arguments to show the team dashboard with current agent status and activity.", + "/team", + ); + return true; + } + case "tasks": { + await ctx.sendMessage( + "Use the agent_list_background_tasks tool with no arguments to list all background agent tasks and their current status.", + "/tasks", + ); + return true; + } + case "workflow": { + if (!args) { + await ctx.sendMessage( + 'Use the mesh_run_workflow tool with action "list" to list available workflows and their status.', + "/workflow", + ); + } else { + await ctx.sendMessage(`/workflow ${args}`); + } + return true; + } + case "rules": { + if (args) { + await ctx.sendMessage( + `Use the semantic_memory_recall tool to search for rules matching: ${args}`, + `/rules ${args}`, + ); + } else { + await ctx.sendMessage( + 'Use the semantic_memory_recall tool with subject pattern "rule:*" to list all active rules.', + "/rules", + ); + } + return true; + } + case "mailbox": { + if (!args) { + await ctx.sendMessage( + "Use the agent_check_inbox tool with no arguments to check the inbox for new messages and show unread count.", + "/mailbox", + ); + } else { + await ctx.sendMessage(`/mailbox ${args}`); + } + return true; + } + case "sync": { + await ctx.sendMessage( + "Use the cortex_sync_status tool with no arguments to show Cortex peer sync status and statistics.", + "/sync", + ); + return true; + } + default: + return false; + } +}; diff --git a/src/tui/tui-cmd-misc.ts b/src/tui/tui-cmd-misc.ts new file mode 100644 index 0000000..09dc30c --- /dev/null +++ b/src/tui/tui-cmd-misc.ts @@ -0,0 +1,251 @@ +/** + * Miscellaneous command group. + * Commands: help, status, undo, abort, settings, bug, init, exit, quit, + * onboard, batch, vim, compact, default (markdown/fallback) + */ + +import { execSync } from "node:child_process"; +import { expandMarkdownCommand, findMarkdownCommand } from "../commands/markdown-commands.js"; +import { helpText } from "./commands.js"; +import { undo, listUndoEntries } from "./undo-manager.js"; +import { compactMessages } from "./compact-handler.js"; +import { formatStatusSummary } from "./tui-status-summary.js"; +import type { GatewayStatusSummary } from "./tui-types.js"; +import type { CommandGroupHandler } from "./tui-cmd-types.js"; + +export const miscCommands: CommandGroupHandler = async (ctx, name, args, raw) => { + const { client, chatLog, tui, state, loadHistory, abortActive } = ctx; + + switch (name) { + case "help": + chatLog.addSystem( + helpText({ + provider: state.sessionInfo.modelProvider, + model: state.sessionInfo.model, + }), + ); + return true; + case "status": + try { + const status = await client.getStatus(); + if (typeof status === "string") { + chatLog.addSystem(status); + return true; + } + if (status && typeof status === "object") { + const lines = formatStatusSummary(status as GatewayStatusSummary); + for (const line of lines) { + chatLog.addSystem(line); + } + return true; + } + chatLog.addSystem("status: unknown response"); + } catch (err) { + chatLog.addSystem(`status failed: ${String(err)}`); + } + return true; + case "vim": + chatLog.addSystem("vim mode is not available in this build"); + return true; + case "undo": { + const cwd = process.cwd(); + if (args === "list") { + const entries = listUndoEntries(cwd); + if (entries.length === 0) { + chatLog.addSystem("No undo points available."); + } else { + const lines = entries.map( + (e) => ` [${e.index}] ${e.label}${e.timestamp ? ` (${e.timestamp})` : ""}`, + ); + chatLog.addSystem(`Undo points:\n${lines.join("\n")}`); + } + } else { + const result = undo(cwd); + chatLog.addSystem(result.message); + } + return true; + } + case "abort": + await abortActive(); + return true; + case "settings": + ctx.openSettings(); + return true; + case "bug": { + const url = "https://github.com/ApiliumCode/mayros/issues/new"; + try { + const openCmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + execSync(`${openCmd} ${url}`, { stdio: "ignore" }); + chatLog.addSystem(`Opened ${url}`); + } catch { + chatLog.addSystem(`Report bugs at: ${url}`); + } + return true; + } + case "init": { + try { + const fs = await import("node:fs"); + const path = await import("node:path"); + const configPath = path.join(process.cwd(), "mayros.json"); + if (fs.existsSync(configPath)) { + chatLog.addSystem("mayros.json already exists in this directory"); + return true; + } + const pkg = fs.existsSync(path.join(process.cwd(), "package.json")) + ? JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8")) + : null; + const projectName = pkg?.name ?? path.basename(process.cwd()); + const config = { + $schema: "https://apilium.com/schemas/mayros/v1.json", + meta: { lastTouchedVersion: "0.1.5" }, + ui: { theme: "dark" }, + agents: { defaults: { agentId: projectName } }, + }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); + chatLog.addSystem(`Created ${configPath}`); + } catch (err) { + chatLog.addSystem(`init failed: ${String(err)}`); + } + return true; + } + case "exit": + case "quit": + client.stop(); + tui.stop(); + process.exit(0); + return true; + case "compact": { + try { + const history = (await client.loadHistory({ + sessionKey: state.currentSessionKey, + })) as { messages?: Array> }; + const rawMessages = history?.messages ?? []; + if (rawMessages.length === 0) { + chatLog.addSystem("nothing to compact"); + return true; + } + const mapped = rawMessages + .filter( + (m) => + typeof m === "object" && m !== null && (m.role === "user" || m.role === "assistant"), + ) + .map((m) => ({ + role: String(m.role ?? "user"), + content: typeof m.content === "string" ? m.content : "", + })); + const compactResult = await compactMessages({ + messages: mapped, + sessionKey: state.currentSessionKey, + }); + // Send the summary back to the gateway: truncate the transcript and + // inject the condensed summary as a system message so the agent + // retains the extracted context going forward. + await client.compactSession({ + key: state.currentSessionKey, + summaryMessage: compactResult.summary, + }); + chatLog.addSystem( + `Compacted ${compactResult.originalCount} messages \u2192 summary (${compactResult.knowledgeItems} knowledge items extracted)`, + ); + await loadHistory(); + } catch (err) { + chatLog.addSystem(`compact failed: ${String(err)}`); + } + return true; + } + case "batch": { + if (!args) { + chatLog.addSystem("usage: /batch [--concurrency N] [--thinking ]"); + return true; + } + // Parse optional flags from args: [--concurrency N] [--thinking level] + const batchArgParts = args.split(/\s+/); + const batchFile = batchArgParts[0] ?? ""; + const batchExtraArgs = batchArgParts.slice(1).join(" "); + + // Verify file exists before attempting to run + try { + const fs = await import("node:fs"); + if (!fs.existsSync(batchFile)) { + chatLog.addSystem(`batch: file not found: ${batchFile}`); + return true; + } + } catch { + chatLog.addSystem(`batch: cannot check file: ${batchFile}`); + return true; + } + + chatLog.addSystem( + `Running batch: ${batchFile}${batchExtraArgs ? " " + batchExtraArgs : ""}...`, + ); + tui.requestRender(); + + try { + const { parseInputFile } = await import("../cli/batch-cli.js"); + const fs = await import("node:fs"); + const content = fs.readFileSync(batchFile, "utf-8"); + const items = parseInputFile(content); + if (items.length === 0) { + chatLog.addSystem("batch: no valid prompts found in file"); + return true; + } + + chatLog.addSystem(`batch: processing ${items.length} prompt(s) sequentially...`); + tui.requestRender(); + + let completed = 0; + const errors: string[] = []; + for (const item of items) { + try { + chatLog.addSystem( + `batch [${completed + 1}/${items.length}]: ${item.prompt.slice(0, 60)}${item.prompt.length > 60 ? "…" : ""}`, + ); + tui.requestRender(); + await ctx.sendMessage(item.context ? `${item.context}\n\n${item.prompt}` : item.prompt); + completed++; + } catch (err) { + const msg = `batch [${completed + 1}/${items.length}] error: ${String(err)}`; + chatLog.addSystem(msg); + errors.push(msg); + completed++; + } + } + chatLog.addSystem(`batch done: ${completed - errors.length} ok, ${errors.length} errors`); + } catch (err) { + chatLog.addSystem(`batch failed: ${String(err)}`); + } + return true; + } + case "onboard": { + try { + chatLog.addSystem("Launching onboarding wizard — stopping TUI..."); + tui.requestRender(); + client.stop(); + tui.stop(); + const { onboardCommand } = await import("../commands/onboard.js"); + const { defaultRuntime } = await import("../runtime.js"); + await onboardCommand({}, defaultRuntime); + process.exit(0); + } catch (err) { + chatLog.addSystem(`onboard failed: ${String(err)}`); + } + return true; + } + default: { + // Check for user-defined markdown commands before sending raw + const mdCmd = findMarkdownCommand(name); + if (mdCmd) { + const expanded = expandMarkdownCommand(mdCmd, args); + await ctx.sendMessage(expanded); + } else { + await ctx.sendMessage(raw); + } + return true; + } + } +}; diff --git a/src/tui/tui-cmd-session.ts b/src/tui/tui-cmd-session.ts new file mode 100644 index 0000000..63f4df8 --- /dev/null +++ b/src/tui/tui-cmd-session.ts @@ -0,0 +1,153 @@ +/** + * Session/model/search command group. + * Commands: agent, agents, session, sessions, model, models, new, reset, search + */ + +import { SessionManager, formatSessionLine } from "./session-manager.js"; +import type { CommandGroupHandler } from "./tui-cmd-types.js"; + +export const sessionCommands: CommandGroupHandler = async (ctx, name, args, _raw) => { + const { client, chatLog, state, refreshSessionInfo, loadHistory, setSession, formatSessionKey } = + ctx; + + switch (name) { + case "agent": + case "agents": + if (!args) { + await ctx.openAgentSelector(); + } else { + await ctx.setAgent(args); + } + return true; + case "session": + case "sessions": { + const sessionSubCmd = args.split(/\s+/)[0]?.toLowerCase(); + const sessionArgs = args.slice((sessionSubCmd ?? "").length).trim(); + + if (!args) { + await ctx.openSessionSelector(); + } else if (sessionSubCmd === "list") { + try { + const mgr = new SessionManager({ + client, + currentAgentId: state.currentAgentId, + }); + const sessions = await mgr.listSessions({ limit: 20 }); + if (sessions.length === 0) { + chatLog.addSystem("no sessions found"); + } else { + const lines = sessions.map((s) => formatSessionLine(s, formatSessionKey)); + chatLog.addSystem( + `Sessions (${sessions.length}):\n${lines.map((l) => ` ${l}`).join("\n")}`, + ); + } + } catch (err) { + chatLog.addSystem(`session list failed: ${String(err)}`); + } + } else if (sessionSubCmd === "rename") { + if (!sessionArgs) { + chatLog.addSystem("usage: /session rename "); + } else { + try { + const mgr = new SessionManager({ + client, + currentAgentId: state.currentAgentId, + }); + await mgr.renameSession(state.currentSessionKey, sessionArgs); + chatLog.addSystem(`session renamed to "${sessionArgs}"`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`session rename failed: ${String(err)}`); + } + } + } else if (sessionSubCmd === "delete") { + if (!sessionArgs) { + chatLog.addSystem("usage: /session delete "); + } else { + try { + const mgr = new SessionManager({ + client, + currentAgentId: state.currentAgentId, + }); + await mgr.deleteSession(sessionArgs); + chatLog.addSystem(`session ${sessionArgs} deleted`); + } catch (err) { + chatLog.addSystem(`session delete failed: ${String(err)}`); + } + } + } else { + // Treat as session key for resume + await setSession(args); + } + return true; + } + case "model": + case "models": + if (!args) { + await ctx.openModelSelector(); + } else { + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + model: args, + }); + chatLog.addSystem(`model set to ${args}`); + ctx.applySessionInfoFromPatch(result); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`model set failed: ${String(err)}`); + } + } + return true; + case "new": + case "reset": + try { + // Clear token counts immediately to avoid stale display (#1523) + state.sessionInfo.inputTokens = null; + state.sessionInfo.outputTokens = null; + state.sessionInfo.totalTokens = null; + ctx.tui.requestRender(); + + await client.resetSession(state.currentSessionKey, name); + chatLog.addSystem(`session ${state.currentSessionKey} reset`); + await loadHistory(); + } catch (err) { + chatLog.addSystem(`reset failed: ${String(err)}`); + } + return true; + case "search": { + const query = args.trim(); + if (!query) { + chatLog.addSystem("Usage: /search "); + return true; + } + chatLog.addSystem(`Searching for "${query}"...`); + try { + const { searchSessions } = await import("../infra/session-search.js"); + const summary = await searchSessions({ query, limit: 10 }); + if (summary.results.length === 0) { + chatLog.addSystem( + `No results found for "${query}" (${summary.sessionsSearched} sessions searched)`, + ); + return true; + } + const lines = [ + `Found ${summary.totalMatches} result(s) in ${summary.sessionsSearched} sessions:`, + ]; + for (const r of summary.results) { + const date = new Date(r.timestamp).toISOString().slice(0, 16).replace("T", " "); + const tag = r.role === "user" ? "[You]" : "[AI]"; + lines.push( + `${date} ${tag} (${r.sessionId}): ${r.snippet.replace(/\n/g, " ").slice(0, 100)}`, + ); + } + chatLog.addSystem(lines.join("\n")); + } catch (err) { + chatLog.addSystem(`search failed: ${String(err)}`); + } + return true; + } + default: + return false; + } +}; diff --git a/src/tui/tui-cmd-teleport.ts b/src/tui/tui-cmd-teleport.ts new file mode 100644 index 0000000..6060c59 --- /dev/null +++ b/src/tui/tui-cmd-teleport.ts @@ -0,0 +1,131 @@ +/** + * Teleport command group (session export/import across devices). + * Command: teleport + */ + +import { copyToClipboard } from "../infra/clipboard.js"; +import { + exportSession, + importSession, + validatePayloadSize, + MAX_EXPORT_MESSAGES, + type TeleportPayload, +} from "./session-teleport.js"; +import type { CommandGroupHandler } from "./tui-cmd-types.js"; + +export const teleportCommands: CommandGroupHandler = async (ctx, name, args, _raw) => { + const { client, chatLog, tui, state } = ctx; + + if (name !== "teleport") { + return false; + } + + const subCmd = args.split(/\s+/)[0]?.toLowerCase(); + if (subCmd === "export") { + // Populate messages from actual session history + let messages: TeleportPayload["messages"] = []; + try { + const history = await client.loadHistory({ + sessionKey: state.currentSessionKey, + limit: MAX_EXPORT_MESSAGES, + }); + if (Array.isArray(history)) { + messages = history.map((m: { role?: string; content?: string; timestamp?: string }) => ({ + role: (m.role as "user" | "assistant" | "system") ?? "user", + content: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), + ...(m.timestamp ? { timestamp: m.timestamp } : {}), + })); + } + } catch { + // If history load fails, export with empty messages (degraded mode) + } + + const payload: TeleportPayload = { + version: 1, + timestamp: new Date().toISOString(), + agentId: state.currentAgentId, + sessionKey: state.currentSessionKey, + messages, + metadata: {}, + }; + + const sizeError = validatePayloadSize(payload); + if (sizeError) { + chatLog.addSystem(`Export warning: ${sizeError}`); + } + + const token = exportSession(payload); + // Copy to clipboard using the cross-platform clipboard helper + // (pbcopy / xclip / wl-copy / clip.exe / PowerShell fallback). + try { + const ok = await copyToClipboard(token); + chatLog.addSystem( + ok + ? `Session exported to clipboard (${token.length} chars, ${messages.length} messages). Share this token to import on another device.` + : `Session token (no clipboard tool found):\n${token}`, + ); + } catch { + chatLog.addSystem(`Session token:\n${token}`); + } + return true; + } + + if (subCmd === "import") { + const token = args.slice("import".length).trim(); + if (!token) { + chatLog.addSystem("Usage: /teleport import "); + return true; + } + try { + const payload = importSession(token); + + // Render each message in the chat log display and inject + // assistant messages into the current session via the gateway. + // User and system messages are shown visually only — they are + // part of the imported context that the user brought over. + let injected = 0; + let failed = 0; + for (const msg of payload.messages) { + if (msg.role === "user") { + chatLog.addUser(msg.content); + } else if (msg.role === "system") { + chatLog.addSystem(msg.content); + } else if (msg.role === "assistant") { + // Persist to the session transcript so the model sees the history + try { + await client.injectChat({ + sessionKey: state.currentSessionKey, + message: msg.content, + label: "teleport", + }); + chatLog.finalizeAssistant(msg.content); + injected++; + } catch { + // Fall back to display-only if injection fails + chatLog.finalizeAssistant(msg.content); + failed++; + } + } + } + + const summaryParts = [`Session imported: ${payload.messages.length} messages`]; + summaryParts.push(`from agent "${payload.agentId}" (${payload.timestamp})`); + if (injected > 0) { + summaryParts.push(`${injected} assistant message(s) written to session transcript`); + } + if (failed > 0) { + summaryParts.push( + `${failed} assistant message(s) displayed only (transcript write failed)`, + ); + } + chatLog.addSystem(summaryParts.join(" — ")); + tui.requestRender(); + } catch (err) { + chatLog.addSystem(`Import failed: ${String(err)}`); + } + return true; + } + + chatLog.addSystem("Usage: /teleport export | /teleport import "); + return true; +}; diff --git a/src/tui/tui-cmd-types.ts b/src/tui/tui-cmd-types.ts new file mode 100644 index 0000000..da1b193 --- /dev/null +++ b/src/tui/tui-cmd-types.ts @@ -0,0 +1,59 @@ +/** + * Shared types for TUI command handler groups. + * + * Each command group module exports a function matching the + * CommandGroupHandler signature. The dispatcher in tui-command-handlers.ts + * iterates over the groups until one returns true (handled). + */ + +import type { Component, TUI } from "@earendil-works/pi-tui"; +import type { SessionsPatchResult } from "../gateway/protocol/index.js"; +import type { ChatLog } from "./components/chat-log.js"; +import type { GatewayChatClient } from "./gateway-chat.js"; +import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; + +/** + * Full context passed to every command group. Includes the base dependencies + * (client, chatLog, tui, state, ...) plus the shared closures (sendMessage, + * setAgent, selectors) that are constructed once in createCommandHandlers + * and reused across groups. + */ +export 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; + mouseHandler?: { enable(): void; disable(): void; isEnabled(): boolean }; + // Shared closures — constructed once, passed to all groups. + sendMessage: (text: string, displayText?: string) => Promise; + setAgent: (id: string) => Promise; + openModelSelector: () => Promise; + openAgentSelector: () => Promise; + openSessionSelector: () => Promise; + openSettings: () => void; +}; + +/** + * A command group handler. Returns true if the command was handled (matched + * a case), false to let the dispatcher try the next group. + */ +export type CommandGroupHandler = ( + ctx: CommandHandlerContext, + name: string, + args: string, + raw: string, +) => Promise; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index c5e830e..a56ea6d 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -1,56 +1,37 @@ import { randomUUID } from "node:crypto"; import type { Component, TUI } from "@earendil-works/pi-tui"; -import { copyToClipboard } from "../infra/clipboard.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; - -const logger = createSubsystemLogger("tui"); -import { - listThinkingLevelLabels, - normalizeUsageDisplay, - resolveResponseUsageMode, -} from "../auto-reply/thinking.js"; -import { expandMarkdownCommand, findMarkdownCommand } from "../commands/markdown-commands.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 { execSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; -import { helpText, parseCommand } from "./commands.js"; -import { undo, listUndoEntries } from "./undo-manager.js"; -import { - exportSession, - importSession, - validatePayloadSize, - MAX_EXPORT_MESSAGES, - type TeleportPayload, -} from "./session-teleport.js"; -import { formatContextVisualization } from "./context-visualizer.js"; -import { renderDiff, renderDiffStats } from "./diff-renderer.js"; -import { compactMessages } from "./compact-handler.js"; -import { SessionManager, formatSessionLine } from "./session-manager.js"; -import { theme } from "./theme/theme.js"; -import { applyOutputStyle, isValidOutputStyle, OUTPUT_STYLE_NAMES } from "./output-styles.js"; -import type { OutputStyle } from "./output-styles.js"; -import { THEME_PRESETS, listCustomThemeNames } from "./theme/palettes.js"; -import type { BuiltinPreset } from "./theme/palettes.js"; -import { setThemePreset, getThemePreset } from "./theme/theme.js"; +import { parseCommand } from "./commands.js"; import type { ChatLog } from "./components/chat-log.js"; import { createFilterableSelectList, createSearchableSelectList, - createSelectList, createSettingsList, } from "./components/selectors.js"; import type { ChatAttachmentInput, GatewayChatClient } from "./gateway-chat.js"; -import { formatStatusSummary } from "./tui-status-summary.js"; -import type { - AgentSummary, - GatewayStatusSummary, - TuiOptions, - TuiStateAccess, -} from "./tui-types.js"; +import { applyOutputStyle } from "./output-styles.js"; +import type { OutputStyle } from "./output-styles.js"; +import type { AgentSummary, TuiOptions, TuiStateAccess } from "./tui-types.js"; +import type { CommandHandlerContext, CommandGroupHandler } from "./tui-cmd-types.js"; +import { sessionCommands } from "./tui-cmd-session.js"; +import { configCommands } from "./tui-cmd-config.js"; +import { displayCommands } from "./tui-cmd-display.js"; +import { clipboardCommands } from "./tui-cmd-clipboard.js"; +import { ecosystemCommands } from "./tui-cmd-ecosystem.js"; +import { teleportCommands } from "./tui-cmd-teleport.js"; +import { miscCommands } from "./tui-cmd-misc.js"; + +export type { CommandHandlerContext } from "./tui-cmd-types.js"; -type CommandHandlerContext = { +/** + * Base context shape that callers (runTui) provide. The shared closures + * (sendMessage, setAgent, selectors) are constructed inside + * createCommandHandlers and added to produce the full CommandHandlerContext + * passed to each command group. + */ +type CommandHandlerBaseContext = { client: GatewayChatClient; chatLog: ChatLog; tui: TUI; @@ -72,7 +53,7 @@ type CommandHandlerContext = { mouseHandler?: { enable(): void; disable(): void; isEnabled(): boolean }; }; -export function createCommandHandlers(context: CommandHandlerContext) { +export function createCommandHandlers(base: CommandHandlerBaseContext) { const { client, chatLog, @@ -86,13 +67,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { loadHistory, setSession, refreshAgents, - abortActive, setActivityStatus, formatSessionKey, applySessionInfoFromPatch, noteLocalRunId, forgetLocalRunId, - } = context; + } = base; const setAgent = async (id: string) => { state.currentAgentId = normalizeAgentId(id); @@ -273,1075 +253,6 @@ export function createCommandHandlers(context: CommandHandlerContext) { 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": - case "agents": - if (!args) { - await openAgentSelector(); - } else { - await setAgent(args); - } - break; - case "session": - case "sessions": { - const sessionSubCmd = args.split(/\s+/)[0]?.toLowerCase(); - const sessionArgs = args.slice((sessionSubCmd ?? "").length).trim(); - - if (!args) { - await openSessionSelector(); - } else if (sessionSubCmd === "list") { - try { - const mgr = new SessionManager({ - client, - currentAgentId: state.currentAgentId, - }); - const sessions = await mgr.listSessions({ limit: 20 }); - if (sessions.length === 0) { - chatLog.addSystem("no sessions found"); - } else { - const lines = sessions.map((s) => formatSessionLine(s, formatSessionKey)); - chatLog.addSystem( - `Sessions (${sessions.length}):\n${lines.map((l) => ` ${l}`).join("\n")}`, - ); - } - } catch (err) { - chatLog.addSystem(`session list failed: ${String(err)}`); - } - } else if (sessionSubCmd === "rename") { - if (!sessionArgs) { - chatLog.addSystem("usage: /session rename "); - } else { - try { - const mgr = new SessionManager({ - client, - currentAgentId: state.currentAgentId, - }); - await mgr.renameSession(state.currentSessionKey, sessionArgs); - chatLog.addSystem(`session renamed to "${sessionArgs}"`); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`session rename failed: ${String(err)}`); - } - } - } else if (sessionSubCmd === "delete") { - if (!sessionArgs) { - chatLog.addSystem("usage: /session delete "); - } else { - try { - const mgr = new SessionManager({ - client, - currentAgentId: state.currentAgentId, - }); - await mgr.deleteSession(sessionArgs); - chatLog.addSystem(`session ${sessionArgs} deleted`); - } catch (err) { - chatLog.addSystem(`session delete failed: ${String(err)}`); - } - } - } else { - // Treat as session key for resume - await setSession(args); - } - break; - } - case "model": - case "models": - 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 "think": - if (!args) { - const levels = listThinkingLevelLabels( - state.sessionInfo.modelProvider, - state.sessionInfo.model, - ); - const currentThink = state.sessionInfo.thinkingLevel ?? "medium"; - const thinkItems = levels.map((l) => ({ - value: l, - label: l === currentThink ? `${l} (current)` : l, - })); - const thinkSelector = createSelectList(thinkItems, thinkItems.length); - thinkSelector.onSelect = (item) => { - void (async () => { - try { - const result = await client.patchSession({ - key: state.currentSessionKey, - thinkingLevel: item.value, - }); - chatLog.addSystem(`thinking set to ${item.value}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`think failed: ${String(err)}`); - } - closeOverlay(); - tui.requestRender(); - })(); - }; - thinkSelector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(thinkSelector); - tui.requestRender(); - 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) { - const verboseOpts = ["on", "off"]; - const currentVerbose = state.sessionInfo.verboseLevel ?? "off"; - const verboseItems = verboseOpts.map((v) => ({ - value: v, - label: v === currentVerbose ? `${v} (current)` : v, - })); - const verboseSelector = createSelectList(verboseItems, verboseItems.length); - verboseSelector.onSelect = (item) => { - void (async () => { - try { - const result = await client.patchSession({ - key: state.currentSessionKey, - verboseLevel: item.value, - }); - chatLog.addSystem(`verbose set to ${item.value}`); - applySessionInfoFromPatch(result); - await loadHistory(); - } catch (err) { - chatLog.addSystem(`verbose failed: ${String(err)}`); - } - closeOverlay(); - tui.requestRender(); - })(); - }; - verboseSelector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(verboseSelector); - tui.requestRender(); - 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) { - const reasoningOpts = ["on", "off"]; - const currentReasoning = state.sessionInfo.reasoningLevel ?? "off"; - const reasoningItems = reasoningOpts.map((r) => ({ - value: r, - label: r === currentReasoning ? `${r} (current)` : r, - })); - const reasoningSelector = createSelectList(reasoningItems, reasoningItems.length); - reasoningSelector.onSelect = (item) => { - void (async () => { - try { - const result = await client.patchSession({ - key: state.currentSessionKey, - reasoningLevel: item.value, - }); - chatLog.addSystem(`reasoning set to ${item.value}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`reasoning failed: ${String(err)}`); - } - closeOverlay(); - tui.requestRender(); - })(); - }; - reasoningSelector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(reasoningSelector); - tui.requestRender(); - 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) { - const elevatedOpts = ["on", "off", "ask", "full"]; - const currentElevated = state.sessionInfo.elevatedLevel ?? "off"; - const elevatedItems = elevatedOpts.map((e) => ({ - value: e, - label: e === currentElevated ? `${e} (current)` : e, - })); - const elevatedSelector = createSelectList(elevatedItems, elevatedItems.length); - elevatedSelector.onSelect = (item) => { - void (async () => { - try { - const result = await client.patchSession({ - key: state.currentSessionKey, - elevatedLevel: item.value, - }); - chatLog.addSystem(`elevated set to ${item.value}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`elevated failed: ${String(err)}`); - } - closeOverlay(); - tui.requestRender(); - })(); - }; - elevatedSelector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(elevatedSelector); - tui.requestRender(); - 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) { - const activationOpts = ["mention", "always"]; - const currentActivation = state.sessionInfo.groupActivation ?? "mention"; - const activationItems = activationOpts.map((a) => ({ - value: a, - label: a === currentActivation ? `${a} (current)` : a, - })); - const activationSelector = createSelectList(activationItems, activationItems.length); - activationSelector.onSelect = (item) => { - void (async () => { - try { - const result = await client.patchSession({ - key: state.currentSessionKey, - groupActivation: item.value === "always" ? "always" : "mention", - }); - chatLog.addSystem(`activation set to ${item.value}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`activation failed: ${String(err)}`); - } - closeOverlay(); - tui.requestRender(); - })(); - }; - activationSelector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(activationSelector); - tui.requestRender(); - 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 "context": { - const used = state.sessionInfo.totalTokens ?? 0; - const max = state.sessionInfo.contextTokens ?? 0; - const lines = formatContextVisualization({ - usedTokens: used, - maxTokens: max, - inputTokens: state.sessionInfo.inputTokens, - outputTokens: state.sessionInfo.outputTokens, - }); - for (const line of lines) { - chatLog.addSystem(line); - } - break; - } - case "diff": { - try { - const cmd = args ? `git diff -- ${args}` : "git diff"; - const raw = execSync(cmd, { encoding: "utf-8", maxBuffer: 1024 * 1024 }).trim(); - if (!raw) { - chatLog.addSystem("no changes"); - break; - } - const stats = renderDiffStats(raw); - chatLog.addSystem( - `${stats.files} file(s) changed, +${stats.additions} -${stats.deletions}`, - ); - for (const line of renderDiff(raw, { - add: theme.success, - del: theme.error, - header: (t) => theme.bold(t), - hunk: theme.accent, - context: theme.dim, - })) { - chatLog.addSystem(line); - } - } catch (err) { - chatLog.addSystem(`diff failed: ${String(err)}`); - } - break; - } - case "style": { - const styleName = args.toLowerCase(); - if (!styleName) { - const currentStyle = state.outputStyle ?? "standard"; - const styleItems = OUTPUT_STYLE_NAMES.map((s) => ({ - value: s, - label: s === currentStyle ? `${s} (current)` : s, - })); - const styleSelector = createSelectList(styleItems, styleItems.length); - styleSelector.onSelect = (item) => { - state.outputStyle = item.value as OutputStyle; - chatLog.addSystem(`output style set to ${item.value}`); - closeOverlay(); - tui.requestRender(); - }; - styleSelector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(styleSelector); - tui.requestRender(); - break; - } - if (!isValidOutputStyle(styleName)) { - chatLog.addSystem(`unknown style. usage: /style <${OUTPUT_STYLE_NAMES.join("|")}>`); - break; - } - state.outputStyle = styleName; - chatLog.addSystem(`output style set to ${styleName}`); - break; - } - case "theme": { - const preset = args.toLowerCase(); - if (!preset) { - const currentTheme = getThemePreset(); - const themeItems = THEME_PRESETS.map((t) => ({ - value: t, - label: t === currentTheme ? `${t} (current)` : t, - })); - const themeSelector = createSelectList(themeItems, themeItems.length); - themeSelector.onSelect = (item) => { - setThemePreset(item.value); - chatLog.addSystem(`theme set to ${item.value}`); - closeOverlay(); - tui.requestRender(); - }; - themeSelector.onCancel = () => { - closeOverlay(); - tui.requestRender(); - }; - openOverlay(themeSelector); - tui.requestRender(); - break; - } - if ( - !THEME_PRESETS.includes(preset as BuiltinPreset) && - !listCustomThemeNames().includes(preset) - ) { - chatLog.addSystem( - `unknown theme. usage: /theme <${THEME_PRESETS.join("|")}> or a custom theme name`, - ); - break; - } - setThemePreset(preset); - chatLog.addSystem(`theme set to ${preset}`); - break; - } - case "new": - 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: ${String(err)}`); - } - break; - case "vim": { - chatLog.addSystem("vim mode is not available in this build"); - break; - } - case "permission": { - const MODES = ["auto", "ask", "deny"] as const; - type PermMode = (typeof MODES)[number]; - const mode = args.toLowerCase(); - if (!mode) { - const current = state.permissionMode ?? "auto"; - const idx = MODES.indexOf(current); - const next = MODES[(idx + 1) % MODES.length] as PermMode; - state.permissionMode = next; - chatLog.addSystem(`permission mode: ${next}`); - } else if (MODES.includes(mode as PermMode)) { - state.permissionMode = mode as PermMode; - chatLog.addSystem(`permission mode set to ${mode}`); - } else { - chatLog.addSystem("usage: /permission "); - } - break; - } - case "fast": { - const isFast = !state.fastMode; - state.fastMode = isFast; - if (isFast) { - // Save current thinking level before switching - state.previousThinkingLevel = state.sessionInfo.thinkingLevel ?? "medium"; - try { - const result = await client.patchSession({ - key: state.currentSessionKey, - thinkingLevel: "off", - }); - applySessionInfoFromPatch(result); - } catch (err) { - // Best-effort — fast mode works locally even without gateway - logger.warn("fast-mode: failed to set thinking off", { error: String(err) }); - } - state.outputStyle = "standard"; - chatLog.addSystem("fast mode enabled (thinking: off, style: standard)"); - } else { - // Restore previous thinking level - const prevLevel = state.previousThinkingLevel ?? "medium"; - try { - const result = await client.patchSession({ - key: state.currentSessionKey, - thinkingLevel: prevLevel, - }); - applySessionInfoFromPatch(result); - } catch (err) { - // Best-effort — restore may fail if gateway is unreachable - logger.warn("fast-mode: failed to restore thinking", { - level: prevLevel, - error: String(err), - }); - } - chatLog.addSystem(`fast mode disabled (thinking: ${prevLevel})`); - } - break; - } - case "compact": { - try { - const history = (await client.loadHistory({ - sessionKey: state.currentSessionKey, - })) as { messages?: Array> }; - const rawMessages = history?.messages ?? []; - if (rawMessages.length === 0) { - chatLog.addSystem("nothing to compact"); - break; - } - const mapped = rawMessages - .filter( - (m) => - typeof m === "object" && - m !== null && - (m.role === "user" || m.role === "assistant"), - ) - .map((m) => ({ - role: String(m.role ?? "user"), - content: typeof m.content === "string" ? m.content : "", - })); - const compactResult = await compactMessages({ - messages: mapped, - sessionKey: state.currentSessionKey, - }); - // Send the summary back to the gateway: truncate the transcript and - // inject the condensed summary as a system message so the agent - // retains the extracted context going forward. - await client.compactSession({ - key: state.currentSessionKey, - summaryMessage: compactResult.summary, - }); - chatLog.addSystem( - `Compacted ${compactResult.originalCount} messages \u2192 summary (${compactResult.knowledgeItems} knowledge items extracted)`, - ); - await loadHistory(); - } catch (err) { - chatLog.addSystem(`compact failed: ${String(err)}`); - } - break; - } - case "copy": { - const lastText = chatLog.getLastAssistantText(); - if (!lastText) { - chatLog.addSystem("nothing to copy"); - break; - } - try { - const ok = await copyToClipboard(lastText); - chatLog.addSystem( - ok ? "last response copied to clipboard" : "copy failed: no clipboard tool found", - ); - } catch (err) { - chatLog.addSystem(`copy failed: ${String(err)}`); - } - break; - } - case "export": { - const lastText = chatLog.getLastAssistantText(); - if (!lastText) { - chatLog.addSystem("nothing to export"); - break; - } - const filePath = args || `mayros-export-${Date.now()}.md`; - try { - writeFileSync(filePath, lastText, "utf-8"); - chatLog.addSystem(`exported to ${filePath}`); - } catch (err) { - chatLog.addSystem(`export failed: ${String(err)}`); - } - break; - } - case "undo": { - const cwd = process.cwd(); - if (args === "list") { - const entries = listUndoEntries(cwd); - if (entries.length === 0) { - chatLog.addSystem("No undo points available."); - } else { - const lines = entries.map( - (e) => ` [${e.index}] ${e.label}${e.timestamp ? ` (${e.timestamp})` : ""}`, - ); - chatLog.addSystem(`Undo points:\n${lines.join("\n")}`); - } - } else { - const result = undo(cwd); - chatLog.addSystem(result.message); - } - break; - } - case "abort": - await abortActive(); - break; - case "settings": - openSettings(); - break; - case "bug": { - const url = "https://github.com/ApiliumCode/mayros/issues/new"; - try { - const openCmd = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "start" - : "xdg-open"; - execSync(`${openCmd} ${url}`, { stdio: "ignore" }); - chatLog.addSystem(`Opened ${url}`); - } catch { - chatLog.addSystem(`Report bugs at: ${url}`); - } - break; - } - case "init": { - try { - const fs = await import("node:fs"); - const path = await import("node:path"); - const configPath = path.join(process.cwd(), "mayros.json"); - if (fs.existsSync(configPath)) { - chatLog.addSystem("mayros.json already exists in this directory"); - break; - } - const pkg = fs.existsSync(path.join(process.cwd(), "package.json")) - ? JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8")) - : null; - const projectName = pkg?.name ?? path.basename(process.cwd()); - const config = { - $schema: "https://apilium.com/schemas/mayros/v1.json", - meta: { lastTouchedVersion: "0.1.5" }, - ui: { theme: "dark" }, - agents: { defaults: { agentId: projectName } }, - }; - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); - chatLog.addSystem(`Created ${configPath}`); - } catch (err) { - chatLog.addSystem(`init failed: ${String(err)}`); - } - break; - } - case "exit": - case "quit": - client.stop(); - tui.stop(); - process.exit(0); - break; - // --- Mayros ecosystem --- - case "plan": { - const action = args || "show"; - await sendMessage(`/plan ${action}`); - break; - } - case "mouse": { - if (context.mouseHandler) { - if (context.mouseHandler.isEnabled()) { - context.mouseHandler.disable(); - chatLog.addSystem("Mouse reporting disabled — text selection enabled."); - } else { - context.mouseHandler.enable(); - chatLog.addSystem("Mouse reporting enabled."); - } - } else { - chatLog.addSystem("Mouse handler not available."); - } - break; - } - case "tools": { - await sendMessage( - "List every tool name you have access to. Output ONLY a numbered list of tool names, nothing else. " + - "Do NOT describe them. Just the names, one per line.", - "/tools", - ); - break; - } - case "kg": { - // Check if semantic memory tools are likely available by asking the gateway - const kgHint = - "You MUST use one of these tools (in order of preference): " + - "memory_stats, semantic_memory_query, semantic_memory_recall. " + - "If none of these tools are available to you, respond EXACTLY with: " + - '"[kg] Plugin memory-semantic is not loaded. Run `mayros plugins list` to check."'; - if (!args) { - await sendMessage( - `Show a knowledge graph summary. ${kgHint} Show categories, triple counts, and recent entries.`, - "/kg", - ); - } else { - await sendMessage(`Search the knowledge graph for: ${args}. ${kgHint}`, `/kg ${args}`); - } - break; - } - case "trace": { - const subCmd = args || "events"; - if (subCmd === "stats") { - await sendMessage( - "Use the trace_stats tool with no arguments to show aggregated observability statistics for the current agent.", - "/trace stats", - ); - } else if (subCmd === "explain" && args.includes(" ")) { - const eventId = args.slice("explain".length).trim(); - await sendMessage( - `Use the trace_explain tool with eventId "${eventId}" to trace the causal chain for that event.`, - `/trace explain ${eventId}`, - ); - } else { - await sendMessage( - "Use the trace_query tool with no arguments to list recent trace events for the current agent.", - "/trace events", - ); - } - break; - } - case "team": { - await sendMessage( - "Use the mesh_team_dashboard tool with no arguments to show the team dashboard with current agent status and activity.", - "/team", - ); - break; - } - case "tasks": { - await sendMessage( - "Use the agent_list_background_tasks tool with no arguments to list all background agent tasks and their current status.", - "/tasks", - ); - break; - } - case "workflow": { - if (!args) { - await sendMessage( - 'Use the mesh_run_workflow tool with action "list" to list available workflows and their status.', - "/workflow", - ); - } else { - await sendMessage(`/workflow ${args}`); - } - break; - } - case "rules": { - if (args) { - await sendMessage( - `Use the semantic_memory_recall tool to search for rules matching: ${args}`, - `/rules ${args}`, - ); - } else { - await sendMessage( - 'Use the semantic_memory_recall tool with subject pattern "rule:*" to list all active rules.', - "/rules", - ); - } - break; - } - case "mailbox": { - if (!args) { - await sendMessage( - "Use the agent_check_inbox tool with no arguments to check the inbox for new messages and show unread count.", - "/mailbox", - ); - } else { - await sendMessage(`/mailbox ${args}`); - } - break; - } - case "search": { - const query = args.trim(); - if (!query) { - chatLog.addSystem("Usage: /search "); - break; - } - chatLog.addSystem(`Searching for "${query}"...`); - try { - const { searchSessions } = await import("../infra/session-search.js"); - const summary = await searchSessions({ query, limit: 10 }); - if (summary.results.length === 0) { - chatLog.addSystem( - `No results found for "${query}" (${summary.sessionsSearched} sessions searched)`, - ); - break; - } - const lines = [ - `Found ${summary.totalMatches} result(s) in ${summary.sessionsSearched} sessions:`, - ]; - for (const r of summary.results) { - const date = new Date(r.timestamp).toISOString().slice(0, 16).replace("T", " "); - const tag = r.role === "user" ? "[You]" : "[AI]"; - lines.push( - `${date} ${tag} (${r.sessionId}): ${r.snippet.replace(/\n/g, " ").slice(0, 100)}`, - ); - } - chatLog.addSystem(lines.join("\n")); - } catch (err) { - chatLog.addSystem(`search failed: ${String(err)}`); - } - break; - } - case "batch": { - if (!args) { - chatLog.addSystem("usage: /batch [--concurrency N] [--thinking ]"); - break; - } - // Parse optional flags from args: [--concurrency N] [--thinking level] - const batchArgParts = args.split(/\s+/); - const batchFile = batchArgParts[0] ?? ""; - const batchExtraArgs = batchArgParts.slice(1).join(" "); - - // Verify file exists before attempting to run - try { - const fs = await import("node:fs"); - if (!fs.existsSync(batchFile)) { - chatLog.addSystem(`batch: file not found: ${batchFile}`); - break; - } - } catch { - chatLog.addSystem(`batch: cannot check file: ${batchFile}`); - break; - } - - chatLog.addSystem( - `Running batch: ${batchFile}${batchExtraArgs ? " " + batchExtraArgs : ""}...`, - ); - tui.requestRender(); - - try { - const { parseInputFile } = await import("../cli/batch-cli.js"); - const fs = await import("node:fs"); - const content = fs.readFileSync(batchFile, "utf-8"); - const items = parseInputFile(content); - if (items.length === 0) { - chatLog.addSystem("batch: no valid prompts found in file"); - break; - } - - chatLog.addSystem(`batch: processing ${items.length} prompt(s) sequentially...`); - tui.requestRender(); - - let completed = 0; - const errors: string[] = []; - for (const item of items) { - try { - chatLog.addSystem( - `batch [${completed + 1}/${items.length}]: ${item.prompt.slice(0, 60)}${item.prompt.length > 60 ? "…" : ""}`, - ); - tui.requestRender(); - await sendMessage(item.context ? `${item.context}\n\n${item.prompt}` : item.prompt); - completed++; - } catch (err) { - const msg = `batch [${completed + 1}/${items.length}] error: ${String(err)}`; - chatLog.addSystem(msg); - errors.push(msg); - completed++; - } - } - chatLog.addSystem(`batch done: ${completed - errors.length} ok, ${errors.length} errors`); - } catch (err) { - chatLog.addSystem(`batch failed: ${String(err)}`); - } - break; - } - case "teleport": { - const subCmd = args.split(/\s+/)[0]?.toLowerCase(); - if (subCmd === "export") { - // Populate messages from actual session history - let messages: TeleportPayload["messages"] = []; - try { - const history = await client.loadHistory({ - sessionKey: state.currentSessionKey, - limit: MAX_EXPORT_MESSAGES, - }); - if (Array.isArray(history)) { - messages = history.map( - (m: { role?: string; content?: string; timestamp?: string }) => ({ - role: (m.role as "user" | "assistant" | "system") ?? "user", - content: - typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), - ...(m.timestamp ? { timestamp: m.timestamp } : {}), - }), - ); - } - } catch { - // If history load fails, export with empty messages (degraded mode) - } - - const payload: TeleportPayload = { - version: 1, - timestamp: new Date().toISOString(), - agentId: state.currentAgentId, - sessionKey: state.currentSessionKey, - messages, - metadata: {}, - }; - - const sizeError = validatePayloadSize(payload); - if (sizeError) { - chatLog.addSystem(`Export warning: ${sizeError}`); - } - - const token = exportSession(payload); - // Copy to clipboard using the cross-platform clipboard helper - // (pbcopy / xclip / wl-copy / clip.exe / PowerShell fallback). - try { - const ok = await copyToClipboard(token); - chatLog.addSystem( - ok - ? `Session exported to clipboard (${token.length} chars, ${messages.length} messages). Share this token to import on another device.` - : `Session token (no clipboard tool found):\n${token}`, - ); - } catch { - chatLog.addSystem(`Session token:\n${token}`); - } - } else if (subCmd === "import") { - const token = args.slice("import".length).trim(); - if (!token) { - chatLog.addSystem("Usage: /teleport import "); - break; - } - try { - const payload = importSession(token); - - // Render each message in the chat log display and inject - // assistant messages into the current session via the gateway. - // User and system messages are shown visually only — they are - // part of the imported context that the user brought over. - let injected = 0; - let failed = 0; - for (const msg of payload.messages) { - if (msg.role === "user") { - chatLog.addUser(msg.content); - } else if (msg.role === "system") { - chatLog.addSystem(msg.content); - } else if (msg.role === "assistant") { - // Persist to the session transcript so the model sees the history - try { - await client.injectChat({ - sessionKey: state.currentSessionKey, - message: msg.content, - label: "teleport", - }); - chatLog.finalizeAssistant(msg.content); - injected++; - } catch { - // Fall back to display-only if injection fails - chatLog.finalizeAssistant(msg.content); - failed++; - } - } - } - - const summaryParts = [`Session imported: ${payload.messages.length} messages`]; - summaryParts.push(`from agent "${payload.agentId}" (${payload.timestamp})`); - if (injected > 0) { - summaryParts.push(`${injected} assistant message(s) written to session transcript`); - } - if (failed > 0) { - summaryParts.push( - `${failed} assistant message(s) displayed only (transcript write failed)`, - ); - } - chatLog.addSystem(summaryParts.join(" — ")); - tui.requestRender(); - } catch (err) { - chatLog.addSystem(`Import failed: ${String(err)}`); - } - } else { - chatLog.addSystem("Usage: /teleport export | /teleport import "); - } - break; - } - case "sync": { - await sendMessage( - "Use the cortex_sync_status tool with no arguments to show Cortex peer sync status and statistics.", - "/sync", - ); - break; - } - case "onboard": { - try { - chatLog.addSystem("Launching onboarding wizard — stopping TUI..."); - tui.requestRender(); - client.stop(); - tui.stop(); - const { onboardCommand } = await import("../commands/onboard.js"); - const { defaultRuntime } = await import("../runtime.js"); - await onboardCommand({}, defaultRuntime); - process.exit(0); - } catch (err) { - chatLog.addSystem(`onboard failed: ${String(err)}`); - } - break; - } - default: { - // Check for user-defined markdown commands before sending raw - const mdCmd = findMarkdownCommand(name); - if (mdCmd) { - const expanded = expandMarkdownCommand(mdCmd, args); - await sendMessage(expanded); - } else { - await sendMessage(raw); - } - break; - } - } - tui.requestRender(); - }; - const sendMessage = async (text: string, displayText?: string) => { try { chatLog.addUser(displayText ?? text); @@ -1390,6 +301,44 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); }; + // Full context with shared closures, passed to every command group. + const ctx: CommandHandlerContext = { + ...base, + sendMessage, + setAgent, + openModelSelector, + openAgentSelector, + openSessionSelector, + openSettings, + }; + + // Groups are tried in order; the first to return true (handled) wins. + const groups: CommandGroupHandler[] = [ + sessionCommands, + configCommands, + displayCommands, + clipboardCommands, + ecosystemCommands, + teleportCommands, + miscCommands, + ]; + + const handleCommand = async (raw: string) => { + const { name, args } = parseCommand(raw); + if (!name) { + return; + } + for (const g of groups) { + if (await g(ctx, name, args, raw)) { + tui.requestRender(); + return; + } + } + // No group matched — should not happen since miscCommands has a default + // fallback, but guard defensively. + tui.requestRender(); + }; + return { handleCommand, sendMessage,