/** * Interactive mode for the coding agent. * Handles TUI rendering and user interaction, delegating business logic to AgentSession. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { SlashCommand } from "@oh-my-pi/pi-tui"; import { CombinedAutocompleteProvider, type Component, Container, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@oh-my-pi/pi-tui"; import { nanoid } from "nanoid"; import { getAuthPath, getDebugLogPath } from "../../config"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session"; import type { ExtensionUIContext } from "../../core/extensions/index"; import { type CustomMessage, createCompactionSummaryMessage } from "../../core/messages"; import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager"; import { loadSlashCommands } from "../../core/slash-commands"; import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../core/terminal-notify"; import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator"; import { setPreferredImageProvider, setPreferredWebSearchProvider } from "../../core/tools/index"; import type { TruncationResult } from "../../core/tools/truncate"; import { VoiceSupervisor } from "../../core/voice-supervisor"; import { disableProvider, enableProvider } from "../../discovery"; import { getChangelogPath, parseChangelog } from "../../utils/changelog"; import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard"; import { registerAsyncCleanup } from "../cleanup"; import { ArminComponent } from "./components/armin"; import { AssistantMessageComponent } from "./components/assistant-message"; import { BashExecutionComponent } from "./components/bash-execution"; import { BorderedLoader } from "./components/bordered-loader"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message"; import { CustomEditor } from "./components/custom-editor"; import { CustomMessageComponent } from "./components/custom-message"; import { DynamicBorder } from "./components/dynamic-border"; import { ExtensionDashboard } from "./components/extensions"; import { HookEditorComponent } from "./components/hook-editor"; import { HookInputComponent } from "./components/hook-input"; import { HookSelectorComponent } from "./components/hook-selector"; import { ModelSelectorComponent } from "./components/model-selector"; import { OAuthSelectorComponent } from "./components/oauth-selector"; import { SessionSelectorComponent } from "./components/session-selector"; import { SettingsSelectorComponent } from "./components/settings-selector"; import { StatusLineComponent } from "./components/status-line"; import { ToolExecutionComponent } from "./components/tool-execution"; import { TreeSelectorComponent } from "./components/tree-selector"; import { TtsrNotificationComponent } from "./components/ttsr-notification"; import { UserMessageComponent } from "./components/user-message"; import { UserMessageSelectorComponent } from "./components/user-message-selector"; import { WelcomeComponent } from "./components/welcome"; import { getAvailableThemes, getEditorTheme, getMarkdownTheme, getSymbolTheme, onThemeChange, setSymbolPreset, setTheme, type Theme, theme, } from "./theme/theme"; /** Interface for components that can be expanded/collapsed */ interface Expandable { setExpanded(expanded: boolean): void; } function isExpandable(obj: unknown): obj is Expandable { return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; } const VOICE_PROGRESS_DELAY_MS = 15000; const VOICE_PROGRESS_MIN_CHARS = 160; const VOICE_PROGRESS_DELTA_CHARS = 120; export class InteractiveMode { private session: AgentSession; private ui: TUI; private chatContainer: Container; private pendingMessagesContainer: Container; private statusContainer: Container; private editor: CustomEditor; private editorContainer: Container; private statusLine: StatusLineComponent; private version: string; private isInitialized = false; private onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void; private loadingAnimation: Loader | undefined = undefined; private lastSigintTime = 0; private lastEscapeTime = 0; private changelogMarkdown: string | undefined = undefined; // Status line tracking (for mutating immediately-sequential status updates) private lastStatusSpacer: Spacer | undefined = undefined; private lastStatusText: Text | undefined = undefined; // Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; private streamingMessage: AssistantMessage | undefined = undefined; // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); // Tool output expansion state private toolOutputExpanded = false; // Thinking block visibility state private hideThinkingBlock = false; // Background mode flag (no UI, no interactive prompts) private isBackgrounded = false; // Agent subscription unsubscribe function private unsubscribe?: () => void; // Signal cleanup unsubscribe function (for SIGINT/SIGTERM flush) private cleanupUnsubscribe?: () => void; // Track if editor is in bash mode (text starts with !) private isBashMode = false; // Track current bash execution component private bashComponent: BashExecutionComponent | undefined = undefined; // Track pending bash components (shown in pending area, moved to chat on submit) private pendingBashComponents: BashExecutionComponent[] = []; // Track pending images from clipboard paste (attached to next message) private pendingImages: ImageContent[] = []; // Voice mode state private voiceSupervisor: VoiceSupervisor; private voiceAutoModeEnabled = false; private voiceProgressTimer: ReturnType | undefined = undefined; private voiceProgressSpoken = false; private voiceProgressLastLength = 0; private lastVoiceInterruptAt = 0; // Auto-compaction state private autoCompactionLoader: Loader | undefined = undefined; private autoCompactionEscapeHandler?: () => void; // Auto-retry state private retryLoader: Loader | undefined = undefined; private retryEscapeHandler?: () => void; // Hook UI state private hookSelector: HookSelectorComponent | undefined = undefined; private hookInput: HookInputComponent | undefined = undefined; private hookEditor: HookEditorComponent | undefined = undefined; // Convenience accessors private get agent() { return this.session.agent; } private get sessionManager() { return this.session.sessionManager; } private get settingsManager() { return this.session.settingsManager; } constructor( session: AgentSession, version: string, changelogMarkdown: string | undefined = undefined, private setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {}, private lspServers: | Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined = undefined, fdPath: string | undefined = undefined, ) { this.session = session; this.version = version; this.changelogMarkdown = changelogMarkdown; this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); this.editor = new CustomEditor(getEditorTheme()); this.editor.setUseTerminalCursor(true); this.editorContainer = new Container(); this.editorContainer.addChild(this.editor); this.statusLine = new StatusLineComponent(session); this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled); this.voiceSupervisor = new VoiceSupervisor(this.session.modelRegistry, { onSendToAgent: async (text) => { await this.submitVoiceText(text); }, onInterruptAgent: async (reason) => { await this.handleVoiceInterrupt(reason); }, onStatus: (status) => { this.setVoiceStatus(status); }, onError: (error) => { this.showError(error.message); this.voiceAutoModeEnabled = false; void this.voiceSupervisor.stop(); this.setVoiceStatus(undefined); }, onWarning: (message) => { this.showWarning(message); }, }); // Define slash commands for autocomplete const slashCommands: SlashCommand[] = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "export", description: "Export session to HTML file or clipboard (--copy)" }, { name: "share", description: "Share session as a secret GitHub gist" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "session", description: "Show session info and stats" }, { name: "extensions", description: "Open Extension Control Center dashboard" }, { name: "status", description: "Alias for /extensions" }, { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "branch", description: "Create a new branch from a previous message" }, { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "login", description: "Login with OAuth provider" }, { name: "logout", description: "Logout from OAuth provider" }, { name: "new", description: "Start a new session" }, { name: "compact", description: "Manually compact the session context" }, { name: "background", description: "Detach UI and continue running in background" }, { name: "bg", description: "Alias for /background" }, { name: "resume", description: "Resume a different session" }, { name: "exit", description: "Exit the application" }, ]; // Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); // Load and convert file commands to SlashCommand format const fileCommands = loadSlashCommands({ cwd: process.cwd() }); const fileSlashCommands: SlashCommand[] = fileCommands.map((cmd) => ({ name: cmd.name, description: cmd.description, })); // Convert hook commands to SlashCommand format const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map((cmd) => ({ name: cmd.name, description: cmd.description ?? "(hook command)", })); // Convert custom commands (TypeScript) to SlashCommand format const customCommands: SlashCommand[] = this.session.customCommands.map((loaded) => ({ name: loaded.command.name, description: `${loaded.command.description} (${loaded.source})`, })); // Setup autocomplete const autocompleteProvider = new CombinedAutocompleteProvider( [...slashCommands, ...fileSlashCommands, ...hookCommands, ...customCommands], process.cwd(), fdPath, ); this.editor.setAutocompleteProvider(autocompleteProvider); } async init(): Promise { if (this.isInitialized) return; // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP) this.cleanupUnsubscribe = registerAsyncCleanup(() => this.sessionManager.flush()); // Get current model info for welcome screen const modelName = this.session.model?.name ?? "Unknown"; const providerName = this.session.model?.provider ?? "Unknown"; // Get recent sessions const recentSessions = getRecentSessions(this.sessionManager.getSessionDir()).map((s) => ({ name: s.name, timeAgo: s.timeAgo, })); // Convert LSP servers to welcome format const lspServerInfo = this.lspServers?.map((s) => ({ name: s.name, status: s.status as "ready" | "error" | "connecting", fileTypes: s.fileTypes, })) ?? []; // Add welcome header const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo); // Set terminal title if session already has one (resumed session) const existingTitle = this.sessionManager.getSessionTitle(); if (existingTitle) { setTerminalTitle(`pi: ${existingTitle}`); } // Setup UI layout this.ui.addChild(new Spacer(1)); this.ui.addChild(welcome); this.ui.addChild(new Spacer(1)); // Add changelog if provided if (this.changelogMarkdown) { this.ui.addChild(new DynamicBorder()); if (this.settingsManager.getCollapseChangelog()) { const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); const latestVersion = versionMatch ? versionMatch[1] : this.version; const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; this.ui.addChild(new Text(condensedText, 1, 0)); } else { this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.ui.addChild(new Spacer(1)); this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); this.ui.addChild(new Spacer(1)); } this.ui.addChild(new DynamicBorder()); } this.ui.addChild(this.chatContainer); this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); this.ui.addChild(new Spacer(1)); this.ui.addChild(this.editorContainer); this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border) this.ui.setFocus(this.editor); this.setupKeyHandlers(); this.setupEditorSubmitHandler(); // Start the UI this.ui.start(); this.isInitialized = true; // Set terminal title const cwdBasename = path.basename(process.cwd()); this.ui.terminal.setTitle(`pi - ${cwdBasename}`); // Initialize hooks with TUI-based UI context await this.initHooksAndCustomTools(); // Subscribe to agent events this.subscribeToAgent(); // Set up theme file watcher onThemeChange(() => { this.ui.invalidate(); this.updateEditorBorderColor(); this.ui.requestRender(); }); // Set up git branch watcher this.statusLine.watchBranch(() => { this.updateEditorTopBorder(); this.ui.requestRender(); }); // Initial top border update this.updateEditorTopBorder(); } // ========================================================================= // Hook System // ========================================================================= /** * Initialize the hook system with TUI-based UI context. */ private async initHooksAndCustomTools(): Promise { // Create and set hook & tool UI context const uiContext: ExtensionUIContext = { select: (title, options) => this.showHookSelector(title, options), confirm: (title, message) => this.showHookConfirm(title, message), input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), setStatus: (key, text) => this.setHookStatus(key, text), setWidget: (key, content) => this.setHookWidget(key, content), setTitle: (title) => setTerminalTitle(title), custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), editor: (title, prefill) => this.showHookEditor(title, prefill), get theme() { return theme; }, }; this.setToolUIContext(uiContext, true); const extensionRunner = this.session.extensionRunner; if (!extensionRunner) { return; // No hooks loaded } extensionRunner.initialize({ getModel: () => this.session.model, sendMessageHandler: (message, options) => { const wasStreaming = this.session.isStreaming; this.session .sendCustomMessage(message, options) .then(() => { // For non-streaming cases with display=true, update UI // (streaming cases update via message_end event) if (!this.isBackgrounded && !wasStreaming && message.display) { this.rebuildChatFromMessages(); } }) .catch((err) => { this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); }); }, appendEntryHandler: (customType, data) => { this.sessionManager.appendCustomEntry(customType, data); }, getActiveToolsHandler: () => this.session.getActiveToolNames(), getAllToolsHandler: () => this.session.getAllToolNames(), setActiveToolsHandler: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames), newSessionHandler: async (options) => { // Stop any loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); // Create new session const success = await this.session.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; } // Call setup callback if provided if (options?.setup) { await options.setup(this.sessionManager); } // Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), ); this.ui.requestRender(); return { cancelled: false }; }, branchHandler: async (entryId) => { const result = await this.session.branch(entryId); if (result.cancelled) { return { cancelled: true }; } // Update UI this.chatContainer.clear(); this.renderInitialMessages(); this.editor.setText(result.selectedText); this.showStatus("Branched to new session"); return { cancelled: false }; }, navigateTreeHandler: async (targetId, options) => { const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); if (result.cancelled) { return { cancelled: true }; } // Update UI this.chatContainer.clear(); this.renderInitialMessages(); if (result.editorText) { this.editor.setText(result.editorText); } this.showStatus("Navigated to selected point"); return { cancelled: false }; }, isIdle: () => !this.session.isStreaming, waitForIdle: () => this.session.agent.waitForIdle(), abort: () => { this.session.abort(); }, hasPendingMessages: () => this.session.queuedMessageCount > 0, uiContext, hasUI: true, }); // Subscribe to extension errors extensionRunner.onError((error) => { this.showExtensionError(error.extensionPath, error.error); }); // Emit session_start event await extensionRunner.emit({ type: "session_start", }); } /** * Set extension widget content. */ private setHookWidget(key: string, content: unknown): void { this.statusLine.setHookStatus(key, String(content)); this.ui.requestRender(); } private initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void { const extensionRunner = this.session.extensionRunner; if (!extensionRunner) { return; } extensionRunner.initialize({ getModel: () => this.session.model, sendMessageHandler: (message, options) => { const wasStreaming = this.session.isStreaming; this.session .sendCustomMessage(message, options) .then(() => { // For non-streaming cases with display=true, update UI // (streaming cases update via message_end event) if (!this.isBackgrounded && !wasStreaming && message.display) { this.rebuildChatFromMessages(); } }) .catch((err: Error) => { const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`; if (this.isBackgrounded) { console.error(errorText); return; } this.showError(errorText); }); }, appendEntryHandler: (customType, data) => { this.sessionManager.appendCustomEntry(customType, data); }, getActiveToolsHandler: () => this.session.getActiveToolNames(), getAllToolsHandler: () => this.session.getAllToolNames(), setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames), newSessionHandler: async (options) => { if (this.isBackgrounded) { return { cancelled: true }; } // Stop any loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); // Create new session const success = await this.session.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; } // Call setup callback if provided if (options?.setup) { await options.setup(this.sessionManager); } // Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), ); this.ui.requestRender(); return { cancelled: false }; }, branchHandler: async (entryId) => { if (this.isBackgrounded) { return { cancelled: true }; } const result = await this.session.branch(entryId); if (result.cancelled) { return { cancelled: true }; } // Update UI this.chatContainer.clear(); this.renderInitialMessages(); this.editor.setText(result.selectedText); this.showStatus("Branched to new session"); return { cancelled: false }; }, navigateTreeHandler: async (targetId, options) => { if (this.isBackgrounded) { return { cancelled: true }; } const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); if (result.cancelled) { return { cancelled: true }; } // Update UI this.chatContainer.clear(); this.renderInitialMessages(); if (result.editorText) { this.editor.setText(result.editorText); } this.showStatus("Navigated to selected point"); return { cancelled: false }; }, isIdle: () => !this.session.isStreaming, waitForIdle: () => this.session.agent.waitForIdle(), abort: () => { this.session.abort(); }, hasPendingMessages: () => this.session.queuedMessageCount > 0, uiContext, hasUI, }); } private createBackgroundUiContext(): ExtensionUIContext { return { select: async (_title: string, _options: string[]) => undefined, confirm: async (_title: string, _message: string) => false, input: async (_title: string, _placeholder?: string) => undefined, notify: () => {}, setStatus: () => {}, setWidget: () => {}, setTitle: () => {}, custom: async ( _factory: ( tui: TUI, theme: Theme, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, ) => undefined as T, setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, get theme() { return theme; }, }; } /** * Emit session event to all extension tools. */ private async emitCustomToolSessionEvent( reason: "start" | "switch" | "branch" | "tree" | "shutdown", previousSessionFile?: string, ): Promise { const event = { reason, previousSessionFile }; const uiContext = this.session.extensionRunner?.getUIContext(); if (!uiContext) { return; } for (const registeredTool of this.session.extensionRunner?.getAllRegisteredTools() ?? []) { if (registeredTool.definition.onSession) { try { await registeredTool.definition.onSession(event, { ui: uiContext, hasUI: !this.isBackgrounded, cwd: this.sessionManager.getCwd(), sessionManager: this.session.sessionManager, modelRegistry: this.session.modelRegistry, model: this.session.model, isIdle: () => !this.session.isStreaming, hasPendingMessages: () => this.session.queuedMessageCount > 0, hasQueuedMessages: () => this.session.queuedMessageCount > 0, abort: () => { this.session.abort(); }, }); } catch (err) { this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err)); } } } } /** * Show a tool error in the chat. */ private showToolError(toolName: string, error: string): void { if (this.isBackgrounded) { console.error(`Tool "${toolName}" error: ${error}`); return; } const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0); this.chatContainer.addChild(errorText); this.ui.requestRender(); } /** * Set hook status text in the footer. */ private setHookStatus(key: string, text: string | undefined): void { if (this.isBackgrounded) { return; } this.statusLine.setHookStatus(key, text); this.ui.requestRender(); } /** * Show a selector for hooks. */ private showHookSelector(title: string, options: string[]): Promise { return new Promise((resolve) => { this.hookSelector = new HookSelectorComponent( title, options, (option) => { this.hideHookSelector(); resolve(option); }, () => { this.hideHookSelector(); resolve(undefined); }, ); this.editorContainer.clear(); this.editorContainer.addChild(this.hookSelector); this.ui.setFocus(this.hookSelector); this.ui.requestRender(); }); } /** * Hide the hook selector. */ private hideHookSelector(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.hookSelector = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } /** * Show a confirmation dialog for hooks. */ private async showHookConfirm(title: string, message: string): Promise { const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]); return result === "Yes"; } /** * Show a text input for hooks. */ private showHookInput(title: string, placeholder?: string): Promise { return new Promise((resolve) => { this.hookInput = new HookInputComponent( title, placeholder, (value) => { this.hideHookInput(); resolve(value); }, () => { this.hideHookInput(); resolve(undefined); }, ); this.editorContainer.clear(); this.editorContainer.addChild(this.hookInput); this.ui.setFocus(this.hookInput); this.ui.requestRender(); }); } /** * Hide the hook input. */ private hideHookInput(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.hookInput = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } /** * Show a multi-line editor for hooks (with Ctrl+G support). */ private showHookEditor(title: string, prefill?: string): Promise { return new Promise((resolve) => { this.hookEditor = new HookEditorComponent( this.ui, title, prefill, (value) => { this.hideHookEditor(); resolve(value); }, () => { this.hideHookEditor(); resolve(undefined); }, ); this.editorContainer.clear(); this.editorContainer.addChild(this.hookEditor); this.ui.setFocus(this.hookEditor); this.ui.requestRender(); }); } /** * Hide the hook editor. */ private hideHookEditor(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.hookEditor = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } /** * Show a notification for hooks. */ private showHookNotify(message: string, type?: "info" | "warning" | "error"): void { if (type === "error") { this.showError(message); } else if (type === "warning") { this.showWarning(message); } else { this.showStatus(message); } } /** * Show a custom component with keyboard focus. */ private async showHookCustom( factory: ( tui: TUI, theme: Theme, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, ): Promise { const savedText = this.editor.getText(); return new Promise((resolve) => { let component: Component & { dispose?(): void }; const close = (result: T) => { component.dispose?.(); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.editor.setText(savedText); this.ui.setFocus(this.editor); this.ui.requestRender(); resolve(result); }; Promise.resolve(factory(this.ui, theme, close)).then((c) => { component = c; this.editorContainer.clear(); this.editorContainer.addChild(component); this.ui.setFocus(component); this.ui.requestRender(); }); }); } /** * Show an extension error in the UI. */ private showExtensionError(extensionPath: string, error: string): void { const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0); this.chatContainer.addChild(errorText); this.ui.requestRender(); } /** * Handle pi.send() from hooks. * If streaming, queue the message. Otherwise, start a new agent loop. */ // ========================================================================= // Key Handlers // ========================================================================= private setupKeyHandlers(): void { this.editor.onEscape = () => { if (this.loadingAnimation) { // Abort and restore queued messages to editor const queuedMessages = this.session.clearQueue(); const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n"); const currentText = this.editor.getText(); const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n"); this.editor.setText(combinedText); this.updatePendingMessagesDisplay(); this.agent.abort(); } else if (this.session.isBashRunning) { this.session.abortBash(); } else if (this.isBashMode) { this.editor.setText(""); this.isBashMode = false; this.updateEditorBorderColor(); } else if (!this.editor.getText().trim()) { // Double-escape with empty editor triggers /tree or /branch based on setting const now = Date.now(); if (now - this.lastEscapeTime < 500) { if (this.settingsManager.getDoubleEscapeAction() === "tree") { this.showTreeSelector(); } else { this.showUserMessageSelector(); } this.lastEscapeTime = 0; } else { this.lastEscapeTime = now; } } }; this.editor.onCtrlC = () => this.handleCtrlC(); this.editor.onCtrlD = () => this.handleCtrlD(); this.editor.onCtrlZ = () => this.handleCtrlZ(); this.editor.onShiftTab = () => this.cycleThinkingLevel(); this.editor.onCtrlP = () => this.cycleRoleModel(); this.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true }); this.editor.onCtrlY = () => this.showModelSelector({ temporaryOnly: true }); // Global debug handler on TUI (works regardless of focus) this.ui.onDebug = () => this.handleDebugCommand(); this.editor.onCtrlL = () => this.showModelSelector(); this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); this.editor.onCtrlG = () => this.openExternalEditor(); this.editor.onQuestionMark = () => this.handleHotkeysCommand(); this.editor.onCtrlV = () => this.handleImagePaste(); // Wire up extension shortcuts this.registerExtensionShortcuts(); this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; this.isBashMode = text.trimStart().startsWith("!"); if (wasBashMode !== this.isBashMode) { this.updateEditorBorderColor(); } }; this.editor.onAltEnter = async (text: string) => { text = text.trim(); if (!text) return; // Alt+Enter queues a follow-up message (waits until agent finishes) // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { this.editor.addToHistory(text); this.editor.setText(""); await this.session.prompt(text, { streamingBehavior: "followUp" }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); } // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit) else if (this.editor.onSubmit) { this.editor.onSubmit(text); } }; } private setupEditorSubmitHandler(): void { this.editor.onSubmit = async (text: string) => { text = text.trim(); // Empty submit while streaming with queued messages: flush queues immediately if (!text && this.session.isStreaming && this.session.queuedMessageCount > 0) { // Abort current stream and let queued messages be processed await this.session.abort(); return; } if (!text) return; // Handle slash commands if (text === "/settings") { this.showSettingsSelector(); this.editor.setText(""); return; } if (text === "/model") { this.showModelSelector(); this.editor.setText(""); return; } if (text.startsWith("/export")) { await this.handleExportCommand(text); this.editor.setText(""); return; } if (text === "/share") { await this.handleShareCommand(); this.editor.setText(""); return; } if (text === "/copy") { await this.handleCopyCommand(); this.editor.setText(""); return; } if (text === "/session") { this.handleSessionCommand(); this.editor.setText(""); return; } if (text === "/changelog") { this.handleChangelogCommand(); this.editor.setText(""); return; } if (text === "/hotkeys") { this.handleHotkeysCommand(); this.editor.setText(""); return; } if (text === "/extensions" || text === "/status") { this.showExtensionsDashboard(); this.editor.setText(""); return; } if (text === "/branch") { if (this.settingsManager.getDoubleEscapeAction() === "tree") { this.showTreeSelector(); } else { this.showUserMessageSelector(); } this.editor.setText(""); return; } if (text === "/tree") { this.showTreeSelector(); this.editor.setText(""); return; } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); return; } if (text === "/logout") { this.showOAuthSelector("logout"); this.editor.setText(""); return; } if (text === "/new") { this.editor.setText(""); await this.handleClearCommand(); return; } if (text === "/compact" || text.startsWith("/compact ")) { const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; this.editor.setText(""); this.editor.disableSubmit = true; try { await this.handleCompactCommand(customInstructions); } finally { this.editor.disableSubmit = false; } return; } if (text === "/background" || text === "/bg") { this.editor.setText(""); this.handleBackgroundCommand(); return; } if (text === "/debug") { this.handleDebugCommand(); this.editor.setText(""); return; } if (text === "/arminsayshi") { this.handleArminSaysHi(); this.editor.setText(""); return; } if (text === "/resume") { this.showSessionSelector(); this.editor.setText(""); return; } if (text === "/exit") { this.editor.setText(""); void this.shutdown(); return; } // Handle bash command (! for normal, !! for excluded from context) if (text.startsWith("!")) { const isExcluded = text.startsWith("!!"); const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); if (command) { if (this.session.isBashRunning) { this.showWarning("A bash command is already running. Press Esc to cancel it first."); this.editor.setText(text); return; } this.editor.addToHistory(text); await this.handleBashCommand(command, isExcluded); this.isBashMode = false; this.updateEditorBorderColor(); return; } } // Block input during compaction if (this.session.isCompacting) { return; } // If streaming, use prompt() with steer behavior // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { this.editor.addToHistory(text); this.editor.setText(""); await this.session.prompt(text, { streamingBehavior: "steer" }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); return; } // Normal message submission // First, move any pending bash components to chat this.flushPendingBashComponents(); // Generate session title on first message const hasUserMessages = this.agent.state.messages.some((m) => m.role === "user"); if (!hasUserMessages && !this.sessionManager.getSessionTitle()) { const registry = this.session.modelRegistry; const smolModel = this.settingsManager.getModelRole("smol"); generateSessionTitle(text, registry, smolModel) .then(async (title) => { if (title) { await this.sessionManager.setSessionTitle(title); setTerminalTitle(`omp: ${title}`); } }) .catch(() => {}); } if (this.onInputCallback) { // Include any pending images from clipboard paste const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined; this.pendingImages = []; this.onInputCallback({ text, images }); } this.editor.addToHistory(text); }; } private subscribeToAgent(): void { this.unsubscribe = this.session.subscribe(async (event) => { await this.handleEvent(event); }); } private async handleEvent(event: AgentSessionEvent): Promise { if (!this.isInitialized) { await this.init(); } this.statusLine.invalidate(); this.updateEditorTopBorder(); switch (event.type) { case "agent_start": if (this.loadingAnimation) { this.loadingAnimation.stop(); } this.statusContainer.clear(); this.loadingAnimation = new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), `Working${theme.format.ellipsis} (esc to interrupt)`, getSymbolTheme().spinnerFrames, ); this.statusContainer.addChild(this.loadingAnimation); this.startVoiceProgressTimer(); this.ui.requestRender(); break; case "message_start": if (event.message.role === "hookMessage" || event.message.role === "custom") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { this.addMessageToChat(event.message); this.editor.setText(""); this.updatePendingMessagesDisplay(); this.ui.requestRender(); } else if (event.message.role === "assistant") { this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock); this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); this.streamingComponent.updateContent(this.streamingMessage); this.ui.requestRender(); } break; case "message_update": if (this.streamingComponent && event.message.role === "assistant") { this.streamingMessage = event.message; this.streamingComponent.updateContent(this.streamingMessage); for (const content of this.streamingMessage.content) { if (content.type === "toolCall") { if (!this.pendingTools.has(content.id)) { this.chatContainer.addChild(new Text("", 0, 0)); const tool = this.session.getToolByName(content.name); const component = new ToolExecutionComponent( content.name, content.arguments, { showImages: this.settingsManager.getShowImages(), }, tool, this.ui, this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(content.id, component); } else { const component = this.pendingTools.get(content.id); if (component) { component.updateArgs(content.arguments); } } } } this.ui.requestRender(); } break; case "message_end": if (event.message.role === "user") break; if (this.streamingComponent && event.message.role === "assistant") { this.streamingMessage = event.message; // Don't show "Aborted" text for TTSR aborts - we'll show a nicer message if (this.session.isTtsrAbortPending && this.streamingMessage.stopReason === "aborted") { // TTSR abort - suppress the "Aborted" rendering in the component const msgWithoutAbort = { ...this.streamingMessage, stopReason: "stop" as const }; this.streamingComponent.updateContent(msgWithoutAbort); } else { this.streamingComponent.updateContent(this.streamingMessage); } if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") { // Skip error handling for TTSR aborts if (!this.session.isTtsrAbortPending) { const errorMessage = this.streamingMessage.stopReason === "aborted" ? "Operation aborted" : this.streamingMessage.errorMessage || "Error"; for (const [, component] of this.pendingTools.entries()) { component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true, }); } } this.pendingTools.clear(); } else { // Args are now complete - trigger diff computation for edit tools for (const [, component] of this.pendingTools.entries()) { component.setArgsComplete(); } } this.streamingComponent = undefined; this.streamingMessage = undefined; this.statusLine.invalidate(); this.updateEditorTopBorder(); } this.ui.requestRender(); break; case "tool_execution_start": { if (!this.pendingTools.has(event.toolCallId)) { const tool = this.session.getToolByName(event.toolName); const component = new ToolExecutionComponent( event.toolName, event.args, { showImages: this.settingsManager.getShowImages(), }, tool, this.ui, this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(event.toolCallId, component); this.ui.requestRender(); } break; } case "tool_execution_update": { const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.partialResult, isError: false }, true); this.ui.requestRender(); } break; } case "tool_execution_end": { const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.result, isError: event.isError }); this.pendingTools.delete(event.toolCallId); this.ui.requestRender(); } break; } case "agent_end": this.stopVoiceProgressTimer(); if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; this.statusContainer.clear(); } if (this.streamingComponent) { this.chatContainer.removeChild(this.streamingComponent); this.streamingComponent = undefined; this.streamingMessage = undefined; } this.pendingTools.clear(); if (this.settingsManager.getVoiceEnabled() && this.voiceAutoModeEnabled) { const lastAssistant = this.findLastAssistantMessage(); if (lastAssistant && lastAssistant.stopReason !== "aborted" && lastAssistant.stopReason !== "error") { const text = this.extractAssistantText(lastAssistant); if (text) { this.voiceSupervisor.notifyResult(text); } } } this.ui.requestRender(); this.sendCompletionNotification(); break; case "auto_compaction_start": { // Disable submit to preserve editor text during compaction this.editor.disableSubmit = true; // Set up escape to abort auto-compaction this.autoCompactionEscapeHandler = this.editor.onEscape; this.editor.onEscape = () => { this.session.abortCompaction(); }; // Show compacting indicator with reason this.statusContainer.clear(); const reasonText = event.reason === "overflow" ? "Context overflow detected, " : ""; this.autoCompactionLoader = new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), `${reasonText}Auto-compacting${theme.format.ellipsis} (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.statusContainer.addChild(this.autoCompactionLoader); this.ui.requestRender(); break; } case "auto_compaction_end": { // Re-enable submit this.editor.disableSubmit = false; // Restore escape handler if (this.autoCompactionEscapeHandler) { this.editor.onEscape = this.autoCompactionEscapeHandler; this.autoCompactionEscapeHandler = undefined; } // Stop loader if (this.autoCompactionLoader) { this.autoCompactionLoader.stop(); this.autoCompactionLoader = undefined; this.statusContainer.clear(); } // Handle result if (event.aborted) { this.showStatus("Auto-compaction cancelled"); } else if (event.result) { // Rebuild chat to show compacted state this.chatContainer.clear(); this.rebuildChatFromMessages(); // Add compaction component at bottom so user sees it without scrolling this.addMessageToChat({ role: "compactionSummary", tokensBefore: event.result.tokensBefore, summary: event.result.summary, timestamp: Date.now(), }); this.statusLine.invalidate(); this.updateEditorTopBorder(); } this.ui.requestRender(); break; } case "auto_retry_start": { // Set up escape to abort retry this.retryEscapeHandler = this.editor.onEscape; this.editor.onEscape = () => { this.session.abortRetry(); }; // Show retry indicator this.statusContainer.clear(); const delaySeconds = Math.round(event.delayMs / 1000); this.retryLoader = new Loader( this.ui, (spinner) => theme.fg("warning", spinner), (text) => theme.fg("muted", text), `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s${theme.format.ellipsis} (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.statusContainer.addChild(this.retryLoader); this.ui.requestRender(); break; } case "auto_retry_end": { // Restore escape handler if (this.retryEscapeHandler) { this.editor.onEscape = this.retryEscapeHandler; this.retryEscapeHandler = undefined; } // Stop loader if (this.retryLoader) { this.retryLoader.stop(); this.retryLoader = undefined; this.statusContainer.clear(); } // Show error only on final failure (success shows normal response) if (!event.success) { this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); } this.ui.requestRender(); break; } case "ttsr_triggered": { // Show a fancy notification when TTSR rules are triggered const component = new TtsrNotificationComponent(event.rules); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.ui.requestRender(); break; } } } private sendCompletionNotification(): void { if (isNotificationSuppressed()) return; const method = this.settingsManager.getNotificationOnComplete(); if (method === "off") return; const protocol = method === "auto" ? detectNotificationProtocol() : method; sendNotification(protocol, "Agent complete"); } /** Extract text content from a user message */ private getUserMessageText(message: Message): string { if (message.role !== "user") return ""; const textBlocks = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content.filter((c: { type: string }) => c.type === "text"); return textBlocks.map((c) => (c as { text: string }).text).join(""); } /** * Show a status message in the chat. * * If multiple status messages are emitted back-to-back (without anything else being added to the chat), * we update the previous status line instead of appending new ones to avoid log spam. */ private showStatus(message: string): void { if (this.isBackgrounded) { return; } const children = this.chatContainer.children; const last = children.length > 0 ? children[children.length - 1] : undefined; const secondLast = children.length > 1 ? children[children.length - 2] : undefined; if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) { this.lastStatusText.setText(theme.fg("dim", message)); this.ui.requestRender(); return; } const spacer = new Spacer(1); const text = new Text(theme.fg("dim", message), 1, 0); this.chatContainer.addChild(spacer); this.chatContainer.addChild(text); this.lastStatusSpacer = spacer; this.lastStatusText = text; this.ui.requestRender(); } private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { switch (message.role) { case "bashExecution": { const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } component.setComplete( message.exitCode, message.cancelled, message.truncated ? ({ truncated: true } as TruncationResult) : undefined, message.fullOutputPath, ); this.chatContainer.addChild(component); break; } case "hookMessage": case "custom": { if (message.display) { const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType); // Both HookMessage and CustomMessage have the same structure, cast for compatibility this.chatContainer.addChild(new CustomMessageComponent(message as CustomMessage, renderer)); } break; } case "compactionSummary": { this.chatContainer.addChild(new Spacer(1)); const component = new CompactionSummaryMessageComponent(message); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; } case "branchSummary": { this.chatContainer.addChild(new Spacer(1)); const component = new BranchSummaryMessageComponent(message); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; } case "fileMention": { // Render compact file mention display for (const file of message.files) { const text = `${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg("muted", "Read")} ${theme.fg( "accent", file.path, )} ${theme.fg("dim", `(${file.lineCount} lines)`)}`; this.chatContainer.addChild(new Text(text, 0, 0)); } break; } case "user": { const textContent = this.getUserMessageText(message); if (textContent) { const userComponent = new UserMessageComponent(textContent); this.chatContainer.addChild(userComponent); if (options?.populateHistory) { this.editor.addToHistory(textContent); } } break; } case "assistant": { const assistantComponent = new AssistantMessageComponent(message, this.hideThinkingBlock); this.chatContainer.addChild(assistantComponent); break; } case "toolResult": { // Tool results are rendered inline with tool calls, handled separately break; } default: { const _exhaustive: never = message; } } } /** * Render session context to chat. Used for initial load and rebuild after compaction. * @param sessionContext Session context to render * @param options.updateFooter Update footer state * @param options.populateHistory Add user messages to editor history */ private renderSessionContext( sessionContext: SessionContext, options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.pendingTools.clear(); if (options.updateFooter) { this.statusLine.invalidate(); this.updateEditorBorderColor(); } for (const message of sessionContext.messages) { // Assistant messages need special handling for tool calls if (message.role === "assistant") { this.addMessageToChat(message); // Render tool call components for (const content of message.content) { if (content.type === "toolCall") { const tool = this.session.getToolByName(content.name); const component = new ToolExecutionComponent( content.name, content.arguments, { showImages: this.settingsManager.getShowImages() }, tool, this.ui, this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); if (message.stopReason === "aborted" || message.stopReason === "error") { const errorMessage = message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error"; component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { this.pendingTools.set(content.id, component); } } } } else if (message.role === "toolResult") { // Match tool results to pending tool components const component = this.pendingTools.get(message.toolCallId); if (component) { component.updateResult(message); this.pendingTools.delete(message.toolCallId); } } else { // All other messages use standard rendering this.addMessageToChat(message, options); } } this.pendingTools.clear(); this.ui.requestRender(); } renderInitialMessages(): void { // Get aligned messages and entries from session context const context = this.sessionManager.buildSessionContext(); this.renderSessionContext(context, { updateFooter: true, populateHistory: true, }); // Show compaction info if session was compacted const allEntries = this.sessionManager.getEntries(); const compactionCount = allEntries.filter((e) => e.type === "compaction").length; if (compactionCount > 0) { const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; this.showStatus(`Session compacted ${times}`); } } async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> { return new Promise((resolve) => { this.onInputCallback = (input) => { this.onInputCallback = undefined; resolve(input); }; }); } private rebuildChatFromMessages(): void { this.chatContainer.clear(); const context = this.sessionManager.buildSessionContext(); this.renderSessionContext(context); } // ========================================================================= // Key handlers // ========================================================================= private handleCtrlC(): void { const now = Date.now(); if (now - this.lastSigintTime < 500) { void this.shutdown(); } else { this.clearEditor(); this.lastSigintTime = now; } } private handleCtrlD(): void { // Only called when editor is empty (enforced by CustomEditor) void this.shutdown(); } /** * Gracefully shutdown the agent. * Emits shutdown event to hooks and tools, then exits. */ private async shutdown(): Promise { this.voiceAutoModeEnabled = false; await this.voiceSupervisor.stop(); // Flush pending session writes before shutdown await this.sessionManager.flush(); // Emit shutdown event to hooks await this.session.emitCustomToolSessionEvent("shutdown"); this.stop(); process.exit(0); } private handleCtrlZ(): void { // Set up handler to restore TUI when resumed process.once("SIGCONT", () => { this.ui.start(); this.ui.requestRender(true); }); // Stop the TUI (restore terminal to normal mode) this.ui.stop(); // Send SIGTSTP to process group (pid=0 means all processes in group) process.kill(0, "SIGTSTP"); } private handleBackgroundCommand(): void { if (this.isBackgrounded) { this.showStatus("Background mode already enabled"); return; } if (!this.session.isStreaming && this.session.queuedMessageCount === 0) { this.showWarning("Agent is idle; nothing to background"); return; } this.isBackgrounded = true; const backgroundUiContext = this.createBackgroundUiContext(); // Background mode disables interactive UI so tools like ask fail fast. this.setToolUIContext(backgroundUiContext, false); this.initializeHookRunner(backgroundUiContext, false); if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } if (this.autoCompactionLoader) { this.autoCompactionLoader.stop(); this.autoCompactionLoader = undefined; } if (this.retryLoader) { this.retryLoader.stop(); this.retryLoader = undefined; } this.statusContainer.clear(); this.statusLine.dispose(); if (this.unsubscribe) { this.unsubscribe(); } this.unsubscribe = this.session.subscribe(async (event) => { await this.handleBackgroundEvent(event); }); // Backgrounding keeps the current process to preserve in-flight agent state. if (this.isInitialized) { this.ui.stop(); this.isInitialized = false; } process.stdout.write("Background mode enabled. Run `bg` to continue in background.\n"); if (process.platform === "win32" || !process.stdout.isTTY) { process.stdout.write("Backgrounding requires POSIX job control; continuing in foreground.\n"); return; } process.kill(0, "SIGTSTP"); } private async handleBackgroundEvent(event: AgentSessionEvent): Promise { if (event.type !== "agent_end") { return; } if (this.session.queuedMessageCount > 0 || this.session.isStreaming) { return; } this.sendCompletionNotification(); await this.shutdown(); } /** * Handle Ctrl+V for image paste from clipboard. * Returns true if an image was found and added, false otherwise. */ private async handleImagePaste(): Promise { try { const image = await readImageFromClipboard(); if (image) { this.pendingImages.push({ type: "image", data: image.data, mimeType: image.mimeType, }); // Insert styled placeholder at cursor like Claude does const imageNum = this.pendingImages.length; const placeholder = theme.bold(theme.underline(`[Image #${imageNum}]`)); this.editor.insertText(`${placeholder} `); this.ui.requestRender(); return true; } // No image in clipboard - show hint this.showStatus("No image in clipboard (use terminal paste for text)"); return false; } catch { this.showStatus("Failed to read clipboard"); return false; } } private setVoiceStatus(text: string | undefined): void { this.statusLine.setHookStatus("voice", text); this.ui.requestRender(); } private async handleVoiceInterrupt(reason?: string): Promise { const now = Date.now(); if (now - this.lastVoiceInterruptAt < 200) return; this.lastVoiceInterruptAt = now; if (this.session.isBashRunning) { this.session.abortBash(); } if (this.session.isStreaming) { await this.session.abort(); } if (reason) { this.showStatus(reason); } } private stopVoiceProgressTimer(): void { if (this.voiceProgressTimer) { clearTimeout(this.voiceProgressTimer); this.voiceProgressTimer = undefined; } } private startVoiceProgressTimer(): void { this.stopVoiceProgressTimer(); if (!this.settingsManager.getVoiceEnabled() || !this.voiceAutoModeEnabled) return; this.voiceProgressSpoken = false; this.voiceProgressLastLength = 0; this.voiceProgressTimer = setTimeout(() => { void this.maybeSpeakProgress(); }, VOICE_PROGRESS_DELAY_MS); } private async maybeSpeakProgress(): Promise { if (!this.session.isStreaming || this.voiceProgressSpoken || !this.voiceAutoModeEnabled) return; const streaming = this.streamingMessage; if (!streaming) return; const text = this.extractAssistantText(streaming); if (!text || text.length < VOICE_PROGRESS_MIN_CHARS) { if (this.session.isStreaming) { this.voiceProgressTimer = setTimeout(() => { void this.maybeSpeakProgress(); }, VOICE_PROGRESS_DELAY_MS); } return; } const delta = text.length - this.voiceProgressLastLength; if (delta < VOICE_PROGRESS_DELTA_CHARS) { if (this.session.isStreaming) { this.voiceProgressTimer = setTimeout(() => { void this.maybeSpeakProgress(); }, VOICE_PROGRESS_DELAY_MS); } return; } this.voiceProgressLastLength = text.length; this.voiceProgressSpoken = true; this.voiceSupervisor.notifyProgress(text); } private async toggleVoiceListening(): Promise { if (!this.settingsManager.getVoiceEnabled()) { this.settingsManager.setVoiceEnabled(true); this.showStatus("Voice mode enabled."); } if (this.voiceAutoModeEnabled) { this.voiceAutoModeEnabled = false; this.stopVoiceProgressTimer(); await this.voiceSupervisor.stop(); this.setVoiceStatus(undefined); this.showStatus("Voice mode disabled."); return; } this.voiceAutoModeEnabled = true; try { await this.voiceSupervisor.start(); } catch (error) { this.voiceAutoModeEnabled = false; this.setVoiceStatus(undefined); this.showError(error instanceof Error ? error.message : String(error)); } } private async submitVoiceText(text: string): Promise { const cleaned = text.trim(); if (!cleaned) { this.showWarning("No speech detected. Try again."); return; } const toSend = cleaned; this.editor.addToHistory(toSend); if (this.session.isStreaming) { await this.session.abort(); await this.session.steer(toSend); this.updatePendingMessagesDisplay(); return; } if (this.onInputCallback) { this.onInputCallback({ text: toSend }); } } private findLastAssistantMessage(): AssistantMessage | undefined { for (let i = this.session.messages.length - 1; i >= 0; i--) { const message = this.session.messages[i]; if (message?.role === "assistant") { return message as AssistantMessage; } } return undefined; } private extractAssistantText(message: AssistantMessage): string { let text = ""; for (const content of message.content) { if (content.type === "text") { text += content.text; } } return text.trim(); } private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); } else { const level = this.session.thinkingLevel || "off"; this.editor.borderColor = theme.getThinkingBorderColor(level); } // Update footer content in editor's top border this.updateEditorTopBorder(); this.ui.requestRender(); } private updateEditorTopBorder(): void { const width = this.ui.getWidth(); const topBorder = this.statusLine.getTopBorder(width); this.editor.setTopBorder(topBorder); } private cycleThinkingLevel(): void { const newLevel = this.session.cycleThinkingLevel(); if (newLevel === undefined) { this.showStatus("Current model does not support thinking"); } else { this.statusLine.invalidate(); this.updateEditorBorderColor(); } } private async cycleRoleModel(options?: { temporary?: boolean }): Promise { try { const result = await this.session.cycleRoleModels(["slow", "default", "smol"], options); if (!result) { this.showStatus("Only one role model available"); return; } this.statusLine.invalidate(); this.updateEditorBorderColor(); const roleLabel = result.role === "default" ? "default" : result.role; const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; const tempLabel = options?.temporary ? " (temporary)" : ""; this.showStatus(`Switched to ${roleLabel}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}`); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } } private toggleToolOutputExpansion(): void { this.toolOutputExpanded = !this.toolOutputExpanded; for (const child of this.chatContainer.children) { if (isExpandable(child)) { child.setExpanded(this.toolOutputExpanded); } } this.ui.requestRender(); } private toggleThinkingBlockVisibility(): void { this.hideThinkingBlock = !this.hideThinkingBlock; this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock); // Rebuild chat from session messages this.chatContainer.clear(); this.rebuildChatFromMessages(); // If streaming, re-add the streaming component with updated visibility and re-render if (this.streamingComponent && this.streamingMessage) { this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock); this.streamingComponent.updateContent(this.streamingMessage); this.chatContainer.addChild(this.streamingComponent); } this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); } private openExternalEditor(): void { // Determine editor (respect $VISUAL, then $EDITOR) const editorCmd = process.env.VISUAL || process.env.EDITOR; if (!editorCmd) { this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); return; } const currentText = this.editor.getText(); const tmpFile = path.join(os.tmpdir(), `omp-editor-${nanoid()}.omp.md`); try { // Write current content to temp file fs.writeFileSync(tmpFile, currentText, "utf-8"); // Stop TUI to release terminal this.ui.stop(); // Split by space to support editor arguments (e.g., "code --wait") const [editor, ...editorArgs] = editorCmd.split(" "); // Spawn editor synchronously with inherited stdio for interactive editing const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], { stdin: "inherit", stdout: "inherit", stderr: "inherit", }); // On successful exit (exitCode 0), replace editor content if (result.exitCode === 0) { const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); this.editor.setText(newContent); } // On non-zero exit, keep original text (no action needed) } finally { // Clean up temp file try { fs.unlinkSync(tmpFile); } catch { // Ignore cleanup errors } // Restart TUI this.ui.start(); this.ui.requestRender(); } } // ========================================================================= // UI helpers // ========================================================================= clearEditor(): void { if (this.isBackgrounded) { return; } this.editor.setText(""); this.pendingImages = []; this.ui.requestRender(); } showError(errorMessage: string): void { if (this.isBackgrounded) { console.error(`Error: ${errorMessage}`); return; } this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); this.ui.requestRender(); } showWarning(warningMessage: string): void { if (this.isBackgrounded) { console.error(`Warning: ${warningMessage}`); return; } this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)); this.ui.requestRender(); } showNewVersionNotification(newVersion: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); this.chatContainer.addChild( new Text( theme.bold(theme.fg("warning", "Update Available")) + "\n" + theme.fg("muted", `New version ${newVersion} is available. Run: `) + theme.fg("accent", "omp update"), 1, 0, ), ); this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); this.ui.requestRender(); } private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); const queuedMessages = this.session.getQueuedMessages(); const steeringMessages = queuedMessages.steering.map((message) => ({ message, label: "Steer" })); const followUpMessages = queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" })); const allMessages = [...steeringMessages, ...followUpMessages]; if (allMessages.length > 0) { this.pendingMessagesContainer.addChild(new Spacer(1)); for (const entry of allMessages) { const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`); this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); } } } /** Move pending bash components from pending area to chat */ private flushPendingBashComponents(): void { for (const component of this.pendingBashComponents) { this.pendingMessagesContainer.removeChild(component); this.chatContainer.addChild(component); } this.pendingBashComponents = []; } // ========================================================================= // Selectors // ========================================================================= /** * Shows a selector component in place of the editor. * @param create Factory that receives a `done` callback and returns the component and focus target */ private showSelector(create: (done: () => void) => { component: Component; focus: Component }): void { const done = () => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); }; const { component, focus } = create(done); this.editorContainer.clear(); this.editorContainer.addChild(component); this.ui.setFocus(focus); this.ui.requestRender(); } private showSettingsSelector(): void { this.showSelector((done) => { const selector = new SettingsSelectorComponent( this.settingsManager, { availableThinkingLevels: this.session.getAvailableThinkingLevels(), thinkingLevel: this.session.thinkingLevel, availableThemes: getAvailableThemes(), cwd: process.cwd(), }, { onChange: (id, value) => this.handleSettingChange(id, value), onThemePreview: (themeName) => { const result = setTheme(themeName, true); if (result.success) { this.ui.invalidate(); this.ui.requestRender(); } }, onStatusLinePreview: (settings) => { // Update status line with preview settings const currentSettings = this.settingsManager.getStatusLineSettings(); this.statusLine.updateSettings({ ...currentSettings, ...settings }); this.updateEditorTopBorder(); this.ui.requestRender(); }, getStatusLinePreview: () => { // Return the rendered status line for inline preview const width = this.ui.getWidth(); return this.statusLine.getTopBorder(width).content; }, onPluginsChanged: () => { this.ui.requestRender(); }, onCancel: () => { done(); // Restore status line to saved settings this.statusLine.updateSettings(this.settingsManager.getStatusLineSettings()); this.updateEditorTopBorder(); this.ui.requestRender(); }, }, ); return { component: selector, focus: selector }; }); } /** * Show the Extension Control Center dashboard. * Replaces /status with a unified view of all providers and extensions. */ private showExtensionsDashboard(): void { this.showSelector((done) => { const dashboard = new ExtensionDashboard(process.cwd(), this.settingsManager, this.ui.terminal.rows); dashboard.onClose = () => { done(); this.ui.requestRender(); }; return { component: dashboard, focus: dashboard }; }); } /** * Handle setting changes from the settings selector. * Most settings are saved directly via SettingsManager in the definitions. * This handles side effects and session-specific settings. */ private handleSettingChange(id: string, value: string | boolean): void { // Discovery provider toggles if (id.startsWith("discovery.")) { const providerId = id.replace("discovery.", ""); if (value) { enableProvider(providerId); } else { disableProvider(providerId); } return; } switch (id) { // Session-managed settings (not in SettingsManager) case "autoCompact": this.session.setAutoCompactionEnabled(value as boolean); this.statusLine.setAutoCompactEnabled(value as boolean); break; case "steeringMode": this.session.setSteeringMode(value as "all" | "one-at-a-time"); break; case "followUpMode": this.session.setFollowUpMode(value as "all" | "one-at-a-time"); break; case "interruptMode": this.session.setInterruptMode(value as "immediate" | "wait"); break; case "thinkingLevel": this.session.setThinkingLevel(value as ThinkingLevel); this.statusLine.invalidate(); this.updateEditorBorderColor(); break; // Settings with UI side effects case "showImages": for (const child of this.chatContainer.children) { if (child instanceof ToolExecutionComponent) { child.setShowImages(value as boolean); } } break; case "hideThinking": this.hideThinkingBlock = value as boolean; for (const child of this.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(value as boolean); } } this.chatContainer.clear(); this.rebuildChatFromMessages(); break; case "theme": { const result = setTheme(value as string, true); this.statusLine.invalidate(); this.updateEditorTopBorder(); this.ui.invalidate(); if (!result.success) { this.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`); } break; } case "symbolPreset": { setSymbolPreset(value as "unicode" | "nerd" | "ascii"); this.statusLine.invalidate(); this.updateEditorTopBorder(); this.ui.invalidate(); break; } case "voiceEnabled": { if (!value) { this.voiceAutoModeEnabled = false; this.stopVoiceProgressTimer(); void this.voiceSupervisor.stop(); this.setVoiceStatus(undefined); } break; } case "statusLinePreset": case "statusLineSeparator": case "statusLineShowHooks": case "statusLineSegments": case "statusLineModelThinking": case "statusLinePathAbbreviate": case "statusLinePathMaxLength": case "statusLinePathStripWorkPrefix": case "statusLineGitShowBranch": case "statusLineGitShowStaged": case "statusLineGitShowUnstaged": case "statusLineGitShowUntracked": case "statusLineTimeFormat": case "statusLineTimeShowSeconds": { this.statusLine.updateSettings(this.settingsManager.getStatusLineSettings()); this.updateEditorTopBorder(); this.ui.requestRender(); break; } // Provider settings - update runtime preferences case "webSearchProvider": setPreferredWebSearchProvider(value as "auto" | "exa" | "perplexity" | "anthropic"); break; case "imageProvider": setPreferredImageProvider(value as "auto" | "gemini" | "openrouter"); break; // All other settings are handled by the definitions (get/set on SettingsManager) // No additional side effects needed } } private showModelSelector(options?: { temporaryOnly?: boolean }): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( this.ui, this.session.model, this.settingsManager, this.session.modelRegistry, this.session.scopedModels, async (model, role) => { try { if (role === "temporary") { // Temporary: update agent state but don't persist to settings await this.session.setModelTemporary(model); this.statusLine.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Temporary model: ${model.id}`); done(); this.ui.requestRender(); } else if (role === "default") { // Default: update agent state and persist await this.session.setModel(model, role); this.statusLine.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Default model: ${model.id}`); // Don't call done() - selector stays open for role assignment } else { // Other roles (smol, slow): just update settings, not current model const roleLabel = role === "smol" ? "Smol" : role; this.showStatus(`${roleLabel} model: ${model.id}`); // Don't call done() - selector stays open } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }, () => { done(); this.ui.requestRender(); }, options, ); return { component: selector, focus: selector }; }); } private showUserMessageSelector(): void { const userMessages = this.session.getUserMessagesForBranching(); if (userMessages.length === 0) { this.showStatus("No messages to branch from"); return; } this.showSelector((done) => { const selector = new UserMessageSelectorComponent( userMessages.map((m) => ({ id: m.entryId, text: m.text })), async (entryId) => { const result = await this.session.branch(entryId); if (result.cancelled) { // Hook cancelled the branch done(); this.ui.requestRender(); return; } this.chatContainer.clear(); this.renderInitialMessages(); this.editor.setText(result.selectedText); done(); this.showStatus("Branched to new session"); }, () => { done(); this.ui.requestRender(); }, ); return { component: selector, focus: selector.getMessageList() }; }); } private showTreeSelector(): void { const tree = this.sessionManager.getTree(); const realLeafId = this.sessionManager.getLeafId(); // Find the visible leaf for display (skip metadata entries like labels) let visibleLeafId = realLeafId; while (visibleLeafId) { const entry = this.sessionManager.getEntry(visibleLeafId); if (!entry) break; if (entry.type !== "label" && entry.type !== "custom") break; visibleLeafId = entry.parentId ?? null; } if (tree.length === 0) { this.showStatus("No entries in session"); return; } this.showSelector((done) => { const selector = new TreeSelectorComponent( tree, visibleLeafId, this.ui.terminal.rows, async (entryId) => { // Selecting the visible leaf is a no-op (already there) if (entryId === visibleLeafId) { done(); this.showStatus("Already at this point"); return; } // Ask about summarization (or skip if disabled in settings) done(); // Close selector first const branchSummariesEnabled = this.settingsManager.getBranchSummaryEnabled(); const wantsSummary = branchSummariesEnabled ? await this.showHookConfirm("Summarize branch?", "Create a summary of the branch you're leaving?") : false; // Set up escape handler and loader if summarizing let summaryLoader: Loader | undefined; const originalOnEscape = this.editor.onEscape; if (wantsSummary) { this.editor.onEscape = () => { this.session.abortBranchSummary(); }; this.chatContainer.addChild(new Spacer(1)); summaryLoader = new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), "Summarizing branch... (esc to cancel)", getSymbolTheme().spinnerFrames, ); this.statusContainer.addChild(summaryLoader); this.ui.requestRender(); } try { const result = await this.session.navigateTree(entryId, { summarize: wantsSummary }); if (result.aborted) { // Summarization aborted - re-show tree selector this.showStatus("Branch summarization cancelled"); this.showTreeSelector(); return; } if (result.cancelled) { this.showStatus("Navigation cancelled"); return; } // Update UI this.chatContainer.clear(); this.renderInitialMessages(); if (result.editorText) { this.editor.setText(result.editorText); } this.showStatus("Navigated to selected point"); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } finally { if (summaryLoader) { summaryLoader.stop(); this.statusContainer.clear(); } this.editor.onEscape = originalOnEscape; } }, () => { done(); this.ui.requestRender(); }, (entryId, label) => { this.sessionManager.appendLabelChange(entryId, label); this.ui.requestRender(); }, ); return { component: selector, focus: selector }; }); } private showSessionSelector(): void { this.showSelector((done) => { const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir()); const selector = new SessionSelectorComponent( sessions, async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); }, () => { done(); this.ui.requestRender(); }, () => { void this.shutdown(); }, ); return { component: selector, focus: selector.getSessionList() }; }); } private async handleResumeSession(sessionPath: string): Promise { // Stop loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); // Clear UI state this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); // Switch session via AgentSession (emits hook and tool session events) await this.session.switchSession(sessionPath); // Clear and re-render the chat this.chatContainer.clear(); this.renderInitialMessages(); this.showStatus("Resumed session"); } private async showOAuthSelector(mode: "login" | "logout"): Promise { if (mode === "logout") { const providers = this.session.modelRegistry.authStorage.list(); const loggedInProviders = providers.filter( (p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth", ); if (loggedInProviders.length === 0) { this.showStatus("No OAuth providers logged in. Use /login first."); return; } } this.showSelector((done) => { const selector = new OAuthSelectorComponent( mode, this.session.modelRegistry.authStorage, async (providerId: string) => { done(); if (mode === "login") { this.showStatus(`Logging in to ${providerId}...`); try { await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { onAuth: (info: { url: string; instructions?: string }) => { this.chatContainer.addChild(new Spacer(1)); // Use OSC 8 hyperlink escape sequence for clickable link const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`; this.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0)); if (info.instructions) { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0)); } this.ui.requestRender(); this.openInBrowser(info.url); }, onPrompt: async (prompt: { message: string; placeholder?: string }) => { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0)); if (prompt.placeholder) { this.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0)); } this.ui.requestRender(); return new Promise((resolve) => { const codeInput = new Input(); codeInput.onSubmit = () => { const code = codeInput.getValue(); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); resolve(code); }; this.editorContainer.clear(); this.editorContainer.addChild(codeInput); this.ui.setFocus(codeInput); this.ui.requestRender(); }); }, onProgress: (message: string) => { this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); this.ui.requestRender(); }, }); // Refresh models to pick up new baseUrl (e.g., github-copilot) await this.session.modelRegistry.refresh(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text( theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0, ), ); this.chatContainer.addChild( new Text(theme.fg("dim", `Credentials saved to ${getAuthPath()}`), 1, 0), ); this.ui.requestRender(); } catch (error: unknown) { this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); } } else { try { await this.session.modelRegistry.authStorage.logout(providerId); // Refresh models to reset baseUrl await this.session.modelRegistry.refresh(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text( theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`), 1, 0, ), ); this.chatContainer.addChild( new Text(theme.fg("dim", `Credentials removed from ${getAuthPath()}`), 1, 0), ); this.ui.requestRender(); } catch (error: unknown) { this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`); } } }, () => { done(); this.ui.requestRender(); }, ); return { component: selector, focus: selector }; }); } // ========================================================================= // Command handlers // ========================================================================= private openInBrowser(urlOrPath: string): void { try { const args = process.platform === "darwin" ? ["open", urlOrPath] : process.platform === "win32" ? ["cmd", "/c", "start", "", urlOrPath] : ["xdg-open", urlOrPath]; Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); } catch { // Best-effort: browser opening is non-critical } } private async handleExportCommand(text: string): Promise { const parts = text.split(/\s+/); const arg = parts.length > 1 ? parts[1] : undefined; // Check for clipboard export if (arg === "--copy" || arg === "clipboard" || arg === "copy") { try { const formatted = this.session.formatSessionAsText(); if (!formatted) { this.showError("No messages to export yet."); return; } await copyToClipboard(formatted); this.showStatus("Session copied to clipboard"); } catch (error: unknown) { this.showError(`Failed to copy session: ${error instanceof Error ? error.message : "Unknown error"}`); } return; } // HTML file export try { const filePath = await this.session.exportToHtml(arg); this.showStatus(`Session exported to: ${filePath}`); this.openInBrowser(filePath); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); } } private async handleShareCommand(): Promise { // Check if gh is available and logged in try { const authResult = Bun.spawnSync(["gh", "auth", "status"]); if (authResult.exitCode !== 0) { this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; } } catch { this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); return; } // Export to a temp file const tmpFile = path.join(os.tmpdir(), "session.html"); try { await this.session.exportToHtml(tmpFile); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; } // Show cancellable loader, replacing the editor const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); this.editorContainer.clear(); this.editorContainer.addChild(loader); this.ui.setFocus(loader); this.ui.requestRender(); const restoreEditor = () => { loader.dispose(); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); try { fs.unlinkSync(tmpFile); } catch { // Ignore cleanup errors } }; // Create a secret gist asynchronously let proc: ReturnType | null = null; loader.onAbort = () => { proc?.kill(); restoreEditor(); this.showStatus("Share cancelled"); }; try { const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], { stdout: "pipe", stderr: "pipe", }); let stdout = ""; let stderr = ""; const stdoutReader = (proc.stdout as ReadableStream).getReader(); const stderrReader = (proc.stderr as ReadableStream).getReader(); const decoder = new TextDecoder(); (async () => { try { while (true) { const { done, value } = await stdoutReader.read(); if (done) break; stdout += decoder.decode(value); } } catch {} })(); (async () => { try { while (true) { const { done, value } = await stderrReader.read(); if (done) break; stderr += decoder.decode(value); } } catch {} })(); proc.exited.then((code) => resolve({ stdout, stderr, code })); }); if (loader.signal.aborted) return; restoreEditor(); if (result.code !== 0) { const errorMsg = result.stderr?.trim() || "Unknown error"; this.showError(`Failed to create gist: ${errorMsg}`); return; } // Extract gist ID from the URL returned by gh // gh returns something like: https://gist.github.com/username/GIST_ID const gistUrl = result.stdout?.trim(); const gistId = gistUrl?.split("/").pop(); if (!gistId) { this.showError("Failed to parse gist ID from gh output"); return; } // Create the preview URL const previewUrl = `https://gistpreview.github.io/?${gistId}`; this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); this.openInBrowser(previewUrl); } catch (error: unknown) { if (!loader.signal.aborted) { restoreEditor(); this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); } } } private async handleCopyCommand(): Promise { const text = this.session.getLastAssistantText(); if (!text) { this.showError("No agent messages to copy yet."); return; } try { await copyToClipboard(text); this.showStatus("Copied last agent message to clipboard"); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } } private handleSessionCommand(): void { const stats = this.session.getSessionStats(); let info = `${theme.bold("Session Info")}\n\n`; info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; info += `${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`; info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`; info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`; info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`; info += `${theme.bold("Tokens")}\n`; info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; if (stats.tokens.cacheRead > 0) { info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; } if (stats.tokens.cacheWrite > 0) { info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`; } info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`; if (stats.cost > 0) { info += `\n${theme.bold("Cost")}\n`; info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`; } this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); } private handleChangelogCommand(): void { const changelogPath = getChangelogPath(); const allEntries = parseChangelog(changelogPath); const changelogMarkdown = allEntries.length > 0 ? allEntries .reverse() .map((e) => e.content) .join("\n\n") : "No changelog entries found."; this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme())); this.chatContainer.addChild(new DynamicBorder()); this.ui.requestRender(); } /** * Register extension-defined keyboard shortcuts with the editor. */ private registerExtensionShortcuts(): void { const runner = this.session.extensionRunner; if (!runner) return; const shortcuts = runner.getShortcuts(); for (const [keyId, shortcut] of shortcuts) { this.editor.setCustomKeyHandler(keyId, () => { const ctx = runner.createCommandContext(); try { shortcut.handler(ctx); } catch (err) { runner.emitError({ extensionPath: shortcut.extensionPath, event: "shortcut", error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }); } }); } } private handleHotkeysCommand(): void { const hotkeys = ` **Navigation** | Key | Action | |-----|--------| | \`Arrow keys\` | Move cursor / browse history (Up when empty) | | \`Option+Left/Right\` | Move by word | | \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line | | \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line | **Editing** | Key | Action | |-----|--------| | \`Enter\` | Send message | | \`Shift+Enter\` / \`Alt+Enter\` | New line | | \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards | | \`Ctrl+U\` | Delete to start of line | | \`Ctrl+K\` | Delete to end of line | **Other** | Key | Action | |-----|--------| | \`Tab\` | Path completion / accept autocomplete | | \`Escape\` | Cancel autocomplete / abort streaming | | \`Ctrl+C\` | Clear editor (first) / exit (second) | | \`Ctrl+D\` | Exit (when editor is empty) | | \`Ctrl+Z\` | Suspend to background | | \`Shift+Tab\` | Cycle thinking level | | \`Ctrl+P\` | Cycle role models (slow/default/smol) | | \`Shift+Ctrl+P\` | Cycle role models (temporary) | | \`Ctrl+Y\` | Select model (temporary) | | \`Ctrl+L\` | Select model (set roles) | | \`Ctrl+O\` | Toggle tool output expansion | | \`Ctrl+T\` | Toggle thinking block visibility | | \`Ctrl+G\` | Edit message in external editor | | \`/\` | Slash commands | | \`!\` | Run bash command | `; this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, getMarkdownTheme())); this.chatContainer.addChild(new DynamicBorder()); this.ui.requestRender(); } private async handleClearCommand(): Promise { // Stop loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); // New session via session (emits hook and tool session events) await this.session.newSession(); // Update status line (token counts, cost reset) this.statusLine.invalidate(); this.updateEditorTopBorder(); // Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), ); this.ui.requestRender(); } private handleDebugCommand(): void { const width = this.ui.terminal.columns; const allLines = this.ui.render(width); const debugLogPath = getDebugLogPath(); const debugData = [ `Debug output at ${new Date().toISOString()}`, `Terminal width: ${width}`, `Total lines: ${allLines.length}`, "", "=== All rendered lines with visible widths ===", ...allLines.map((line, idx) => { const vw = visibleWidth(line); const escaped = JSON.stringify(line); return `[${idx}] (w=${vw}) ${escaped}`; }), "", "=== Agent messages (JSONL) ===", ...this.session.messages.map((msg) => JSON.stringify(msg)), "", ].join("\n"); fs.mkdirSync(path.dirname(debugLogPath), { recursive: true }); fs.writeFileSync(debugLogPath, debugData); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text( `${theme.fg("accent", `${theme.status.success} Debug log written`)}\n${theme.fg("muted", debugLogPath)}`, 1, 1, ), ); this.ui.requestRender(); } private handleArminSaysHi(): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new ArminComponent(this.ui)); this.ui.requestRender(); } private async handleBashCommand(command: string, excludeFromContext = false): Promise { const isDeferred = this.session.isStreaming; this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); if (isDeferred) { // Show in pending area when agent is streaming this.pendingMessagesContainer.addChild(this.bashComponent); this.pendingBashComponents.push(this.bashComponent); } else { // Show in chat immediately when agent is idle this.chatContainer.addChild(this.bashComponent); } this.ui.requestRender(); try { const result = await this.session.executeBash( command, (chunk) => { if (this.bashComponent) { this.bashComponent.appendOutput(chunk); this.ui.requestRender(); } }, { excludeFromContext }, ); if (this.bashComponent) { this.bashComponent.setComplete( result.exitCode, result.cancelled, result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined, result.fullOutputPath, ); } } catch (error) { if (this.bashComponent) { this.bashComponent.setComplete(undefined, false); } this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`); } this.bashComponent = undefined; this.ui.requestRender(); } private async handleCompactCommand(customInstructions?: string): Promise { const entries = this.sessionManager.getEntries(); const messageCount = entries.filter((e) => e.type === "message").length; if (messageCount < 2) { this.showWarning("Nothing to compact (no messages yet)"); return; } await this.executeCompaction(customInstructions, false); } private async executeCompaction(customInstructions?: string, isAuto = false): Promise { // Stop loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); // Set up escape handler during compaction const originalOnEscape = this.editor.onEscape; this.editor.onEscape = () => { this.session.abortCompaction(); }; // Show compacting status this.chatContainer.addChild(new Spacer(1)); const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)"; const compactingLoader = new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), label, getSymbolTheme().spinnerFrames, ); this.statusContainer.addChild(compactingLoader); this.ui.requestRender(); try { const result = await this.session.compact(customInstructions); // Rebuild UI this.rebuildChatFromMessages(); // Add compaction component at bottom so user sees it without scrolling const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString()); this.addMessageToChat(msg); this.statusLine.invalidate(); this.updateEditorTopBorder(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) { this.showError("Compaction cancelled"); } else { this.showError(`Compaction failed: ${message}`); } } finally { compactingLoader.stop(); this.statusContainer.clear(); this.editor.onEscape = originalOnEscape; } } stop(): void { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusLine.dispose(); if (this.unsubscribe) { this.unsubscribe(); } if (this.cleanupUnsubscribe) { this.cleanupUnsubscribe(); } if (this.isInitialized) { this.ui.stop(); this.isInitialized = false; } } }