Spaces:
Paused
Paused
| import { | |
| CombinedAutocompleteProvider, | |
| Container, | |
| Loader, | |
| ProcessTerminal, | |
| Text, | |
| TUI, | |
| } from "@mariozechner/pi-tui"; | |
| import type { | |
| AgentSummary, | |
| SessionInfo, | |
| SessionScope, | |
| TuiOptions, | |
| TuiStateAccess, | |
| } from "./tui-types.js"; | |
| import { resolveDefaultAgentId } from "../agents/agent-scope.js"; | |
| import { loadConfig } from "../config/config.js"; | |
| import { | |
| buildAgentMainSessionKey, | |
| normalizeAgentId, | |
| normalizeMainKey, | |
| parseAgentSessionKey, | |
| } from "../routing/session-key.js"; | |
| import { getSlashCommands } from "./commands.js"; | |
| import { ChatLog } from "./components/chat-log.js"; | |
| import { CustomEditor } from "./components/custom-editor.js"; | |
| import { GatewayChatClient } from "./gateway-chat.js"; | |
| import { editorTheme, theme } from "./theme/theme.js"; | |
| import { createCommandHandlers } from "./tui-command-handlers.js"; | |
| import { createEventHandlers } from "./tui-event-handlers.js"; | |
| import { formatTokens } from "./tui-formatters.js"; | |
| import { createLocalShellRunner } from "./tui-local-shell.js"; | |
| import { createOverlayHandlers } from "./tui-overlays.js"; | |
| import { createSessionActions } from "./tui-session-actions.js"; | |
| import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js"; | |
| export { resolveFinalAssistantText } from "./tui-formatters.js"; | |
| export type { TuiOptions } from "./tui-types.js"; | |
| export function createEditorSubmitHandler(params: { | |
| editor: { | |
| setText: (value: string) => void; | |
| addToHistory: (value: string) => void; | |
| }; | |
| handleCommand: (value: string) => Promise<void> | void; | |
| sendMessage: (value: string) => Promise<void> | void; | |
| handleBangLine: (value: string) => Promise<void> | void; | |
| }) { | |
| return (text: string) => { | |
| const raw = text; | |
| const value = raw.trim(); | |
| params.editor.setText(""); | |
| // Keep previous behavior: ignore empty/whitespace-only submissions. | |
| if (!value) { | |
| return; | |
| } | |
| // Bash mode: only if the very first character is '!' and it's not just '!'. | |
| // IMPORTANT: use the raw (untrimmed) text so leading spaces do NOT trigger. | |
| // Per requirement: a lone '!' should be treated as a normal message. | |
| if (raw.startsWith("!") && raw !== "!") { | |
| params.editor.addToHistory(raw); | |
| void params.handleBangLine(raw); | |
| return; | |
| } | |
| // Enable built-in editor prompt history navigation (up/down). | |
| params.editor.addToHistory(value); | |
| if (value.startsWith("/")) { | |
| void params.handleCommand(value); | |
| return; | |
| } | |
| void params.sendMessage(value); | |
| }; | |
| } | |
| export async function runTui(opts: TuiOptions) { | |
| const config = loadConfig(); | |
| const initialSessionInput = (opts.session ?? "").trim(); | |
| let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; | |
| let sessionMainKey = normalizeMainKey(config.session?.mainKey); | |
| let agentDefaultId = resolveDefaultAgentId(config); | |
| let currentAgentId = agentDefaultId; | |
| let agents: AgentSummary[] = []; | |
| const agentNames = new Map<string, string>(); | |
| let currentSessionKey = ""; | |
| let initialSessionApplied = false; | |
| 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; | |
| const deliverDefault = opts.deliver ?? false; | |
| const autoMessage = opts.message?.trim(); | |
| 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; | |
| const state: TuiStateAccess = { | |
| get agentDefaultId() { | |
| return agentDefaultId; | |
| }, | |
| set agentDefaultId(value) { | |
| agentDefaultId = value; | |
| }, | |
| get sessionMainKey() { | |
| return sessionMainKey; | |
| }, | |
| set sessionMainKey(value) { | |
| sessionMainKey = value; | |
| }, | |
| get sessionScope() { | |
| return sessionScope; | |
| }, | |
| set sessionScope(value) { | |
| sessionScope = value; | |
| }, | |
| get agents() { | |
| return agents; | |
| }, | |
| set agents(value) { | |
| agents = value; | |
| }, | |
| get currentAgentId() { | |
| return currentAgentId; | |
| }, | |
| set currentAgentId(value) { | |
| currentAgentId = value; | |
| }, | |
| get currentSessionKey() { | |
| return currentSessionKey; | |
| }, | |
| set currentSessionKey(value) { | |
| currentSessionKey = value; | |
| }, | |
| get currentSessionId() { | |
| return currentSessionId; | |
| }, | |
| set currentSessionId(value) { | |
| currentSessionId = value; | |
| }, | |
| get activeChatRunId() { | |
| return activeChatRunId; | |
| }, | |
| set activeChatRunId(value) { | |
| activeChatRunId = value; | |
| }, | |
| get historyLoaded() { | |
| return historyLoaded; | |
| }, | |
| set historyLoaded(value) { | |
| historyLoaded = value; | |
| }, | |
| get sessionInfo() { | |
| return sessionInfo; | |
| }, | |
| set sessionInfo(value) { | |
| sessionInfo = value; | |
| }, | |
| get initialSessionApplied() { | |
| return initialSessionApplied; | |
| }, | |
| set initialSessionApplied(value) { | |
| initialSessionApplied = value; | |
| }, | |
| get isConnected() { | |
| return isConnected; | |
| }, | |
| set isConnected(value) { | |
| isConnected = value; | |
| }, | |
| get autoMessageSent() { | |
| return autoMessageSent; | |
| }, | |
| set autoMessageSent(value) { | |
| autoMessageSent = value; | |
| }, | |
| get toolsExpanded() { | |
| return toolsExpanded; | |
| }, | |
| set toolsExpanded(value) { | |
| toolsExpanded = value; | |
| }, | |
| get showThinking() { | |
| return showThinking; | |
| }, | |
| set showThinking(value) { | |
| showThinking = value; | |
| }, | |
| get connectionStatus() { | |
| return connectionStatus; | |
| }, | |
| set connectionStatus(value) { | |
| connectionStatus = value; | |
| }, | |
| get activityStatus() { | |
| return activityStatus; | |
| }, | |
| set activityStatus(value) { | |
| activityStatus = value; | |
| }, | |
| get statusTimeout() { | |
| return statusTimeout; | |
| }, | |
| set statusTimeout(value) { | |
| statusTimeout = value; | |
| }, | |
| get lastCtrlCAt() { | |
| return lastCtrlCAt; | |
| }, | |
| set lastCtrlCAt(value) { | |
| lastCtrlCAt = value; | |
| }, | |
| }; | |
| const client = new GatewayChatClient({ | |
| url: opts.url, | |
| token: opts.token, | |
| password: opts.password, | |
| }); | |
| const tui = new TUI(new ProcessTerminal()); | |
| const header = new Text("", 1, 0); | |
| const statusContainer = new Container(); | |
| const footer = new Text("", 1, 0); | |
| const chatLog = new ChatLog(); | |
| const editor = new CustomEditor(tui, editorTheme); | |
| const root = new Container(); | |
| root.addChild(header); | |
| root.addChild(chatLog); | |
| root.addChild(statusContainer); | |
| root.addChild(footer); | |
| root.addChild(editor); | |
| const updateAutocompleteProvider = () => { | |
| editor.setAutocompleteProvider( | |
| new CombinedAutocompleteProvider( | |
| getSlashCommands({ | |
| cfg: config, | |
| provider: sessionInfo.modelProvider, | |
| model: sessionInfo.model, | |
| }), | |
| process.cwd(), | |
| ), | |
| ); | |
| }; | |
| tui.addChild(root); | |
| tui.setFocus(editor); | |
| const formatSessionKey = (key: string) => { | |
| if (key === "global" || key === "unknown") { | |
| return key; | |
| } | |
| const parsed = parseAgentSessionKey(key); | |
| return parsed?.rest ?? key; | |
| }; | |
| const formatAgentLabel = (id: string) => { | |
| const name = agentNames.get(id); | |
| return name ? `${id} (${name})` : id; | |
| }; | |
| const resolveSessionKey = (raw?: string) => { | |
| const trimmed = (raw ?? "").trim(); | |
| if (sessionScope === "global") { | |
| return "global"; | |
| } | |
| if (!trimmed) { | |
| return buildAgentMainSessionKey({ | |
| agentId: currentAgentId, | |
| mainKey: sessionMainKey, | |
| }); | |
| } | |
| if (trimmed === "global" || trimmed === "unknown") { | |
| return trimmed; | |
| } | |
| if (trimmed.startsWith("agent:")) { | |
| return trimmed; | |
| } | |
| return `agent:${currentAgentId}:${trimmed}`; | |
| }; | |
| currentSessionKey = resolveSessionKey(initialSessionInput); | |
| const updateHeader = () => { | |
| const sessionLabel = formatSessionKey(currentSessionKey); | |
| const agentLabel = formatAgentLabel(currentAgentId); | |
| header.setText( | |
| theme.header( | |
| `openclaw 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(); | |
| }; | |
| 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 footerParts = [ | |
| `agent ${agentLabel}`, | |
| `session ${sessionLabel}`, | |
| modelLabel, | |
| think !== "off" ? `think ${think}` : null, | |
| verbose !== "off" ? `verbose ${verbose}` : null, | |
| reasoningLabel, | |
| tokens, | |
| ].filter(Boolean); | |
| footer.setText(theme.dim(footerParts.join(" | "))); | |
| }; | |
| const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor); | |
| const initialSessionAgentId = (() => { | |
| if (!initialSessionInput) { | |
| return null; | |
| } | |
| const parsed = parseAgentSessionKey(initialSessionInput); | |
| return parsed ? normalizeAgentId(parsed.agentId) : null; | |
| })(); | |
| const sessionActions = createSessionActions({ | |
| client, | |
| chatLog, | |
| tui, | |
| opts, | |
| state, | |
| agentNames, | |
| initialSessionInput, | |
| initialSessionAgentId, | |
| resolveSessionKey, | |
| updateHeader, | |
| updateFooter, | |
| updateAutocompleteProvider, | |
| setActivityStatus, | |
| }); | |
| const { refreshAgents, refreshSessionInfo, loadHistory, setSession, abortActive } = | |
| sessionActions; | |
| const { handleChatEvent, handleAgentEvent } = createEventHandlers({ | |
| chatLog, | |
| tui, | |
| state, | |
| setActivityStatus, | |
| refreshSessionInfo, | |
| }); | |
| const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } = | |
| createCommandHandlers({ | |
| client, | |
| chatLog, | |
| tui, | |
| opts, | |
| state, | |
| deliverDefault, | |
| openOverlay, | |
| closeOverlay, | |
| refreshSessionInfo, | |
| loadHistory, | |
| setSession, | |
| refreshAgents, | |
| abortActive, | |
| setActivityStatus, | |
| formatSessionKey, | |
| }); | |
| const { runLocalShellLine } = createLocalShellRunner({ | |
| chatLog, | |
| tui, | |
| openOverlay, | |
| closeOverlay, | |
| }); | |
| updateAutocompleteProvider(); | |
| editor.onSubmit = createEditorSubmitHandler({ | |
| editor, | |
| handleCommand, | |
| sendMessage, | |
| handleBangLine: runLocalShellLine, | |
| }); | |
| editor.onEscape = () => { | |
| void abortActive(); | |
| }; | |
| editor.onCtrlC = () => { | |
| const now = Date.now(); | |
| if (editor.getText().trim().length > 0) { | |
| editor.setText(""); | |
| setActivityStatus("cleared input"); | |
| tui.requestRender(); | |
| return; | |
| } | |
| if (now - lastCtrlCAt < 1000) { | |
| client.stop(); | |
| tui.stop(); | |
| process.exit(0); | |
| } | |
| lastCtrlCAt = now; | |
| setActivityStatus("press ctrl+c again to exit"); | |
| tui.requestRender(); | |
| }; | |
| editor.onCtrlD = () => { | |
| client.stop(); | |
| tui.stop(); | |
| process.exit(0); | |
| }; | |
| editor.onCtrlO = () => { | |
| toolsExpanded = !toolsExpanded; | |
| chatLog.setToolsExpanded(toolsExpanded); | |
| setActivityStatus(toolsExpanded ? "tools expanded" : "tools collapsed"); | |
| tui.requestRender(); | |
| }; | |
| editor.onCtrlL = () => { | |
| void openModelSelector(); | |
| }; | |
| editor.onCtrlG = () => { | |
| void openAgentSelector(); | |
| }; | |
| editor.onCtrlP = () => { | |
| void openSessionSelector(); | |
| }; | |
| editor.onCtrlT = () => { | |
| showThinking = !showThinking; | |
| void loadHistory(); | |
| }; | |
| client.onEvent = (evt) => { | |
| if (evt.event === "chat") { | |
| handleChatEvent(evt.payload); | |
| } | |
| if (evt.event === "agent") { | |
| handleAgentEvent(evt.payload); | |
| } | |
| }; | |
| client.onConnected = () => { | |
| isConnected = true; | |
| const reconnected = wasDisconnected; | |
| wasDisconnected = false; | |
| setConnectionStatus("connected"); | |
| void (async () => { | |
| await refreshAgents(); | |
| updateHeader(); | |
| 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 reasonLabel = reason?.trim() ? reason.trim() : "closed"; | |
| setConnectionStatus(`gateway disconnected: ${reasonLabel}`, 5000); | |
| setActivityStatus("idle"); | |
| updateFooter(); | |
| tui.requestRender(); | |
| }; | |
| client.onGap = (info) => { | |
| setConnectionStatus(`event gap: expected ${info.expected}, got ${info.received}`, 5000); | |
| tui.requestRender(); | |
| }; | |
| updateHeader(); | |
| setConnectionStatus("connecting"); | |
| updateFooter(); | |
| tui.start(); | |
| client.start(); | |
| } | |