import * as fs from "node:fs/promises"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@oh-my-pi/pi-natives"; import { $env } from "@oh-my-pi/pi-utils"; import type { SettingPath, SettingValue } from "../../config/settings"; import { settings } from "../../config/settings"; import { theme } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import type { AgentSessionEvent } from "../../session/agent-session"; import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { resizeImage } from "../../utils/image-resize"; import { generateSessionTitle, setTerminalTitle } from "../../utils/title-generator"; 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"; } export class InputController { constructor(private ctx: InteractiveModeContext) {} setupKeyHandlers(): void { this.ctx.editor.onEscape = () => { if (this.ctx.loadingAnimation) { this.restoreQueuedMessagesToEditor({ abort: true }); } else if (this.ctx.session.isBashRunning) { this.ctx.session.abortBash(); } else if (this.ctx.isBashMode) { this.ctx.editor.setText(""); this.ctx.isBashMode = false; this.ctx.updateEditorBorderColor(); } else if (this.ctx.session.isPythonRunning) { this.ctx.session.abortPython(); } else if (this.ctx.isPythonMode) { this.ctx.editor.setText(""); this.ctx.isPythonMode = false; this.ctx.updateEditorBorderColor(); } else if (!this.ctx.editor.getText().trim()) { // Double-escape with empty editor triggers /tree, /branch, or nothing based on setting const action = settings.get("doubleEscapeAction"); if (action !== "none") { const now = Date.now(); if (now - this.ctx.lastEscapeTime < 500) { if (action === "tree") { this.ctx.showTreeSelector(); } else { this.ctx.showUserMessageSelector(); } this.ctx.lastEscapeTime = 0; } else { this.ctx.lastEscapeTime = now; } } } }; this.ctx.editor.onCtrlC = () => this.handleCtrlC(); this.ctx.editor.onCtrlD = () => this.handleCtrlD(); this.ctx.editor.onCtrlZ = () => this.handleCtrlZ(); this.ctx.editor.onShiftTab = () => this.cycleThinkingLevel(); this.ctx.editor.onCtrlP = () => this.cycleRoleModel(); this.ctx.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true }); this.ctx.editor.onAltP = () => this.ctx.showModelSelector({ temporaryOnly: true }); // Global debug handler on TUI (works regardless of focus) this.ctx.ui.onDebug = () => this.ctx.showDebugSelector(); this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector(); this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch(); this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion(); this.ctx.editor.onCtrlG = () => void this.openExternalEditor(); this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand(); this.ctx.editor.onCtrlV = () => this.handleImagePaste(); this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt(); // Wire up extension shortcuts this.registerExtensionShortcuts(); const expandToolsKeys = this.ctx.keybindings.getKeys("expandTools"); this.ctx.editor.onCtrlO = expandToolsKeys.includes("ctrl+o") ? () => this.toggleToolOutputExpansion() : undefined; for (const key of expandToolsKeys) { if (key === "ctrl+o") continue; this.ctx.editor.setCustomKeyHandler(key, () => this.toggleToolOutputExpansion()); } const dequeueKeys = this.ctx.keybindings.getKeys("dequeue"); this.ctx.editor.onAltUp = dequeueKeys.includes("alt+up") ? () => this.handleDequeue() : undefined; for (const key of dequeueKeys) { if (key === "alt+up") continue; this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue()); } const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode"); for (const key of planModeKeys) { this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand()); } for (const key of this.ctx.keybindings.getKeys("newSession")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand()); } for (const key of this.ctx.keybindings.getKeys("tree")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector()); } for (const key of this.ctx.keybindings.getKeys("fork")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector()); } for (const key of this.ctx.keybindings.getKeys("resume")) { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector()); } for (const key of this.ctx.keybindings.getKeys("followUp")) { this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp()); } for (const key of this.ctx.keybindings.getKeys("toggleSTT")) { this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle()); } this.ctx.editor.onChange = (text: string) => { const wasBashMode = this.ctx.isBashMode; const wasPythonMode = this.ctx.isPythonMode; const trimmed = text.trimStart(); this.ctx.isBashMode = text.trimStart().startsWith("!"); this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${"); if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) { this.ctx.updateEditorBorderColor(); } }; } setupEditorSubmitHandler(): void { this.ctx.editor.onSubmit = async (text: string) => { text = text.trim(); // Empty submit while streaming with queued messages: flush queues immediately if (!text && this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount > 0) { // Abort current stream and let queued messages be processed await this.ctx.session.abort(); return; } if (!text) return; // Continue shortcuts: "." or "c" sends empty message (agent continues, no visible message) if (text === "." || text === "c") { if (this.ctx.onInputCallback) { this.ctx.editor.setText(""); this.ctx.pendingImages = []; this.ctx.onInputCallback({ text: "" }); } return; } const runner = this.ctx.session.extensionRunner; let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; if (runner?.hasHandlers("input")) { const result = await runner.emitInput(text, inputImages, "interactive"); if (result?.handled) { this.ctx.editor.setText(""); this.ctx.pendingImages = []; return; } if (result?.text !== undefined) { text = result.text.trim(); } if (result?.images !== undefined) { inputImages = result.images; } } if (!text) return; // Handle slash commands if (text === "/settings") { this.ctx.showSettingsSelector(); this.ctx.editor.setText(""); return; } if (text === "/plan") { await this.ctx.handlePlanModeCommand(); this.ctx.editor.setText(""); return; } if (text === "/model" || text === "/models") { this.ctx.showModelSelector(); this.ctx.editor.setText(""); return; } if (text.startsWith("/export")) { await this.ctx.handleExportCommand(text); this.ctx.editor.setText(""); return; } if (text === "/dump") { await this.ctx.handleDumpCommand(); this.ctx.editor.setText(""); return; } if (text === "/share") { await this.ctx.handleShareCommand(); this.ctx.editor.setText(""); return; } if (text === "/browser" || text.startsWith("/browser ")) { const arg = text.slice(8).trim().toLowerCase(); const current = settings.get("browser.headless" as SettingPath) as boolean; let next = current; if (!(settings.get("browser.enabled" as SettingPath) as boolean)) { this.ctx.showWarning("Browser tool is disabled (enable in settings)"); this.ctx.editor.setText(""); return; } if (!arg) { next = !current; } else if (["headless", "hidden"].includes(arg)) { next = true; } else if (["visible", "show", "headful"].includes(arg)) { next = false; } else { this.ctx.showStatus("Usage: /browser [headless|visible]"); this.ctx.editor.setText(""); return; } settings.set("browser.headless" as SettingPath, next as SettingValue); const tool = this.ctx.session.getToolByName("browser"); if (tool && "restartForModeChange" in tool) { try { await (tool as { restartForModeChange: () => Promise }).restartForModeChange(); } catch (error) { this.ctx.showWarning( `Failed to restart browser: ${error instanceof Error ? error.message : String(error)}`, ); this.ctx.editor.setText(""); return; } } this.ctx.showStatus(`Browser mode: ${next ? "headless" : "visible"}`); this.ctx.editor.setText(""); return; } if (text === "/copy") { await this.ctx.handleCopyCommand(); this.ctx.editor.setText(""); return; } if (text === "/session") { await this.ctx.handleSessionCommand(); this.ctx.editor.setText(""); return; } if (text === "/usage") { await this.ctx.handleUsageCommand(); this.ctx.editor.setText(""); return; } if (text === "/changelog") { await this.ctx.handleChangelogCommand(); this.ctx.editor.setText(""); return; } if (text === "/hotkeys") { this.ctx.handleHotkeysCommand(); this.ctx.editor.setText(""); return; } if (text === "/extensions" || text === "/status") { this.ctx.showExtensionsDashboard(); this.ctx.editor.setText(""); return; } if (text === "/branch") { if (settings.get("doubleEscapeAction") === "tree") { this.ctx.showTreeSelector(); } else { this.ctx.showUserMessageSelector(); } this.ctx.editor.setText(""); return; } if (text === "/tree") { this.ctx.showTreeSelector(); this.ctx.editor.setText(""); return; } if (text === "/login") { this.ctx.showOAuthSelector("login"); this.ctx.editor.setText(""); return; } if (text === "/logout") { this.ctx.showOAuthSelector("logout"); this.ctx.editor.setText(""); return; } if (text === "/new") { this.ctx.editor.setText(""); await this.ctx.handleClearCommand(); return; } if (text === "/fork") { this.ctx.editor.setText(""); await this.ctx.handleForkCommand(); return; } if (text === "/move" || text.startsWith("/move ")) { const targetPath = text.slice(6).trim(); if (!targetPath) { this.ctx.showError("Usage: /move "); this.ctx.editor.setText(""); return; } this.ctx.editor.setText(""); await this.ctx.handleMoveCommand(targetPath); return; } if (text === "/compact" || text.startsWith("/compact ")) { const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; this.ctx.editor.setText(""); await this.ctx.handleCompactCommand(customInstructions); return; } if (text === "/handoff" || text.startsWith("/handoff ")) { const customInstructions = text.startsWith("/handoff ") ? text.slice(9).trim() : undefined; this.ctx.editor.setText(""); await this.ctx.handleHandoffCommand(customInstructions); return; } if (text === "/background" || text === "/bg") { this.ctx.editor.setText(""); this.handleBackgroundCommand(); return; } if (text === "/debug") { this.ctx.showDebugSelector(); this.ctx.editor.setText(""); return; } if (text === "/memory" || text.startsWith("/memory ")) { this.ctx.editor.setText(""); await this.ctx.handleMemoryCommand(text); return; } if (text === "/resume") { this.ctx.showSessionSelector(); this.ctx.editor.setText(""); return; } if (text === "/quit" || text === "/exit") { this.ctx.editor.setText(""); void this.ctx.shutdown(); return; } // Handle MCP server management commands if (text === "/mcp" || text.startsWith("/mcp ")) { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); await this.ctx.handleMCPCommand(text); return; } // Handle skill commands (/skill:name [args]) if (text.startsWith("/skill:")) { const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); const skillPath = this.ctx.skillCommands?.get(commandName); if (skillPath) { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); try { const content = await Bun.file(skillPath).text(); const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); const metaLines = [`Skill: ${skillPath}`]; if (args) { metaLines.push(`User: ${args}`); } const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; const skillName = commandName.slice("skill:".length); const details: SkillPromptDetails = { name: skillName || commandName, path: skillPath, args: args || undefined, lineCount: body ? body.split("\n").length : 0, }; await this.ctx.session.promptCustomMessage( { customType: SKILL_PROMPT_MESSAGE_TYPE, content: message, display: true, details, }, { streamingBehavior: "followUp" }, ); } catch (err) { this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`); } 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.ctx.session.isBashRunning) { this.ctx.showWarning("A bash command is already running. Press Esc to cancel it first."); this.ctx.editor.setText(text); return; } this.ctx.editor.addToHistory(text); await this.ctx.handleBashCommand(command, isExcluded); this.ctx.isBashMode = false; this.ctx.updateEditorBorderColor(); return; } } // Handle python command ($ for normal, $$ for excluded from context) if (text.startsWith("$")) { const isExcluded = text.startsWith("$$"); const code = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); if (code) { if (this.ctx.session.isPythonRunning) { this.ctx.showWarning("A Python execution is already running. Press Esc to cancel it first."); this.ctx.editor.setText(text); return; } this.ctx.editor.addToHistory(text); await this.ctx.handlePythonCommand(code, isExcluded); this.ctx.isPythonMode = false; this.ctx.updateEditorBorderColor(); return; } } // Queue input during compaction if (this.ctx.session.isCompacting) { if (this.ctx.pendingImages.length > 0) { this.ctx.showStatus("Compaction in progress. Retry after it completes to send images."); return; } this.ctx.queueCompactionMessage(text, "steer"); return; } // If streaming, use prompt() with steer behavior // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.ctx.session.isStreaming) { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined; this.ctx.pendingImages = []; await this.ctx.session.prompt(text, { streamingBehavior: "steer", images }); this.ctx.updatePendingMessagesDisplay(); this.ctx.ui.requestRender(); return; } // Normal message submission // First, move any pending bash components to chat this.ctx.flushPendingBashComponents(); // Generate session title on first message const hasUserMessages = this.ctx.agent.state.messages.some((m: AgentMessage) => m.role === "user"); if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) { const registry = this.ctx.session.modelRegistry; const smolModel = this.ctx.settings.getModelRole("smol"); generateSessionTitle(text, registry, smolModel, this.ctx.session.sessionId) .then(async title => { if (title) { await this.ctx.sessionManager.setSessionName(title); setTerminalTitle(`π: ${title}`); } }) .catch(() => {}); } if (this.ctx.onInputCallback) { // Include any pending images from clipboard paste const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined; this.ctx.pendingImages = []; this.ctx.onInputCallback({ text, images }); } this.ctx.editor.addToHistory(text); }; } handleCtrlC(): void { const now = Date.now(); if (now - this.ctx.lastSigintTime < 500) { void this.ctx.shutdown(); } else { this.ctx.clearEditor(); this.ctx.lastSigintTime = now; } } handleCtrlD(): void { // Only called when editor is empty (enforced by CustomEditor) void this.ctx.shutdown(); } handleCtrlZ(): void { // Set up handler to restore TUI when resumed process.once("SIGCONT", () => { this.ctx.ui.start(); this.ctx.ui.requestRender(true); }); // Stop the TUI (restore terminal to normal mode) this.ctx.ui.stop(); // Send SIGTSTP to process group (pid=0 means all processes in group) process.kill(0, "SIGTSTP"); } handleDequeue(): void { const restored = this.restoreQueuedMessagesToEditor(); if (restored === 0) { this.ctx.showStatus("No queued messages to restore"); } else { this.ctx.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`); } } /** Send editor text as a follow-up message (queued behind current stream). */ async handleFollowUp(): Promise { const text = this.ctx.editor.getText().trim(); if (!text) return; if (this.ctx.session.isCompacting) { this.ctx.queueCompactionMessage(text, "followUp"); return; } if (this.ctx.session.isStreaming) { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); await this.ctx.session.prompt(text, { streamingBehavior: "followUp" }); this.ctx.updatePendingMessagesDisplay(); this.ctx.ui.requestRender(); return; } // Not streaming — just submit normally this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); await this.ctx.session.prompt(text); } restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number { const { steering, followUp } = this.ctx.session.clearQueue(); const allQueued = [...steering, ...followUp]; if (allQueued.length === 0) { this.ctx.updatePendingMessagesDisplay(); if (options?.abort) { this.ctx.agent.abort(); } return 0; } const queuedText = allQueued.join("\n\n"); const currentText = options?.currentText ?? this.ctx.editor.getText(); const combinedText = [queuedText, currentText].filter(t => t.trim()).join("\n\n"); this.ctx.editor.setText(combinedText); this.ctx.updatePendingMessagesDisplay(); if (options?.abort) { this.ctx.agent.abort(); } return allQueued.length; } handleBackgroundCommand(): void { if (this.ctx.isBackgrounded) { this.ctx.showStatus("Background mode already enabled"); return; } if (!this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0) { this.ctx.showWarning("Agent is idle; nothing to background"); return; } this.ctx.isBackgrounded = true; const backgroundUiContext = this.ctx.createBackgroundUiContext(); // Background mode disables interactive UI so tools like ask fail fast. this.ctx.setToolUIContext(backgroundUiContext, false); this.ctx.initializeHookRunner(backgroundUiContext, false); if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } if (this.ctx.autoCompactionLoader) { this.ctx.autoCompactionLoader.stop(); this.ctx.autoCompactionLoader = undefined; } if (this.ctx.retryLoader) { this.ctx.retryLoader.stop(); this.ctx.retryLoader = undefined; } this.ctx.statusContainer.clear(); this.ctx.statusLine.dispose(); if (this.ctx.unsubscribe) { this.ctx.unsubscribe(); } this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => { await this.ctx.handleBackgroundEvent(event); }); // Backgrounding keeps the current process to preserve in-flight agent state. if (this.ctx.isInitialized) { this.ctx.ui.stop(); this.ctx.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"); } async handleImagePaste(): Promise { try { const image = await readImageFromClipboard(); if (image) { const base64Data = image.data.toBase64(); let imageData = { data: base64Data, mimeType: image.mimeType }; if (settings.get("images.autoResize")) { try { const resized = await resizeImage({ type: "image", data: base64Data, mimeType: image.mimeType, }); imageData = { data: resized.data, mimeType: resized.mimeType }; } catch { imageData = { data: base64Data, mimeType: image.mimeType }; } } this.ctx.pendingImages.push({ type: "image", data: imageData.data, mimeType: imageData.mimeType, }); // Insert placeholder at cursor like Claude does const imageNum = this.ctx.pendingImages.length; const placeholder = `[Image #${imageNum}]`; this.ctx.editor.insertText(`${placeholder} `); this.ctx.ui.requestRender(); return true; } // No image in clipboard - show hint this.ctx.showStatus("No image in clipboard (use terminal paste for text)"); return false; } catch { this.ctx.showStatus("Failed to read clipboard"); return false; } } /** Copy current prompt text to system clipboard. */ handleCopyPrompt(): void { const text = this.ctx.editor.getText(); if (!text) { this.ctx.showStatus("Nothing to copy"); return; } copyToClipboard(text) .then(() => { const sanitized = sanitizeText(text); const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized; this.ctx.showStatus(`Copied: ${preview}`); }) .catch(() => { this.ctx.showWarning("Failed to copy to clipboard"); }); } cycleThinkingLevel(): void { const newLevel = this.ctx.session.cycleThinkingLevel(); if (newLevel === undefined) { this.ctx.showStatus("Current model does not support thinking"); } else { this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); } } async cycleRoleModel(options?: { temporary?: boolean }): Promise { try { const roleOrder = ["slow", "default", "smol"] as const; const result = await this.ctx.session.cycleRoleModels(roleOrder, options); if (!result) { this.ctx.showStatus("Only one role model available"); return; } this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); const roleLabel = result.role === "default" ? "default" : result.role; const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel)); const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; const tempLabel = options?.temporary ? " (temporary)" : ""; const cycleSeparator = theme.fg("dim", " > "); const cycleLabel = roleOrder .map(role => { if (role === result.role) { return theme.bold(theme.fg("accent", role)); } return theme.fg("muted", role); }) .join(cycleSeparator); const orderLabel = ` (cycle: ${cycleLabel})`; this.ctx.showStatus( `Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`, { dim: false }, ); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } } toggleToolOutputExpansion(): void { this.setToolsExpanded(!this.ctx.toolOutputExpanded); } setToolsExpanded(expanded: boolean): void { this.ctx.toolOutputExpanded = expanded; for (const child of this.ctx.chatContainer.children) { if (isExpandable(child)) { child.setExpanded(expanded); } } this.ctx.ui.requestRender(); } toggleThinkingBlockVisibility(): void { this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock; settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock); // Rebuild chat from session messages this.ctx.chatContainer.clear(); this.ctx.rebuildChatFromMessages(); // If streaming, re-add the streaming component with updated visibility and re-render if (this.ctx.streamingComponent && this.ctx.streamingMessage) { this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock); this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage); this.ctx.chatContainer.addChild(this.ctx.streamingComponent); } this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`); } #getEditorTerminalPath(): string | null { if (process.platform === "win32") { return null; } return "/dev/tty"; } async #openEditorTerminalHandle(): Promise { const terminalPath = this.#getEditorTerminalPath(); if (!terminalPath) { return null; } try { return await fs.open(terminalPath, "r+"); } catch { return null; } } async openExternalEditor(): Promise { const editorCmd = getEditorCommand(); if (!editorCmd) { this.ctx.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); return; } const currentText = this.ctx.editor.getText(); let ttyHandle: fs.FileHandle | null = null; try { ttyHandle = await this.#openEditorTerminalHandle(); this.ctx.ui.stop(); const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle ? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd] : ["inherit", "inherit", "inherit"]; const result = await openInEditor(editorCmd, currentText, { extension: ".omp.md", stdio }); if (result !== null) { this.ctx.editor.setText(result); } } catch (error) { this.ctx.showWarning( `Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`, ); } finally { if (ttyHandle) { await ttyHandle.close(); } this.ctx.ui.start(); this.ctx.ui.requestRender(); } } registerExtensionShortcuts(): void { const runner = this.ctx.session.extensionRunner; if (!runner) return; const shortcuts = runner.getShortcuts(); for (const [keyId, shortcut] of shortcuts) { this.ctx.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, }); } }); } } }