import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui"; import { Spacer, Text } from "@oh-my-pi/pi-tui"; import { logger } from "@oh-my-pi/pi-utils"; import { KeybindingsManager } from "../../config/keybindings"; import type { ExtensionActions, ExtensionCommandContextActions, ExtensionContextActions, ExtensionError, ExtensionUIContext, ExtensionUIDialogOptions, TerminalInputHandler, } from "../../extensibility/extensions"; import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookInputComponent } from "../../modes/components/hook-input"; import { HookSelectorComponent } from "../../modes/components/hook-selector"; import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import { setTerminalTitle } from "../../utils/title-generator"; export class ExtensionUiController { #hookSelectorOverlay: OverlayHandle | undefined; #hookInputOverlay: OverlayHandle | undefined; #extensionTerminalInputUnsubscribers = new Set<() => void>(); readonly #dialogOverlayOptions = { anchor: "bottom-center", width: "80%", minWidth: 40, maxHeight: "70%", margin: 1, } as const; constructor(private ctx: InteractiveModeContext) {} /** * Initialize the hook system with TUI-based UI context. */ async initHooksAndCustomTools(): Promise { // Create and set hook & tool UI context const uiContext: ExtensionUIContext = { select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions), confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message), input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions), notify: (message, type) => this.showHookNotify(message, type), onTerminalInput: handler => this.addExtensionTerminalInputListener(handler), setStatus: (key, text) => this.setHookStatus(key, text), setWorkingMessage: message => this.ctx.setWorkingMessage(message), setWidget: (key, content) => this.setHookWidget(key, content), setTitle: title => setTerminalTitle(title), custom: (factory, options) => this.showHookCustom(factory, options), setEditorText: text => this.ctx.editor.setText(text), pasteToEditor: text => { this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`); }, getEditorText: () => this.ctx.editor.getText(), editor: (title, prefill) => this.showHookEditor(title, prefill), get theme() { return theme; }, getAllThemes: async () => (await getAvailableThemesWithPaths()).map(t => ({ name: t.name, path: t.path })), getTheme: name => getThemeByName(name), setTheme: async themeArg => { if (typeof themeArg === "string") { return await setTheme(themeArg, true); } // Theme object passed directly - not supported in current implementation return Promise.resolve({ success: false, error: "Direct theme object not supported" }); }, setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, getToolsExpanded: () => this.ctx.toolOutputExpanded, setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded), }; this.ctx.setToolUIContext(uiContext, true); const extensionRunner = this.ctx.session.extensionRunner; if (!extensionRunner) { return; // No hooks loaded } const actions: ExtensionActions = { sendMessage: (message, options) => { const wasStreaming = this.ctx.session.isStreaming; this.ctx.session .sendCustomMessage(message, options) .then(() => { // For non-streaming cases with display=true, update UI // (streaming cases update via message_end event) if (!this.ctx.isBackgrounded && !wasStreaming && message.display) { this.ctx.rebuildChatFromMessages(); } }) .catch((err: unknown) => { this.ctx.showError( `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`, ); }); }, sendUserMessage: (content, options) => { this.ctx.session.sendUserMessage(content, options).catch((err: unknown) => { this.ctx.showError( `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`, ); }); }, appendEntry: (customType, data) => { this.ctx.sessionManager.appendCustomEntry(customType, data); }, setLabel: (targetId, label) => { this.ctx.sessionManager.appendLabelChange(targetId, label); }, getActiveTools: () => this.ctx.session.getActiveToolNames(), getAllTools: () => this.ctx.session.getAllToolNames(), setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames), setModel: async model => { const key = await this.ctx.session.modelRegistry.getApiKey(model); if (!key) return false; await this.ctx.session.setModel(model); return true; }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: level => this.ctx.session.setThinkingLevel(level), getCommands: () => [], }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, isIdle: () => !this.ctx.session.isStreaming, abort: () => this.ctx.session.abort(), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, shutdown: () => { // Signal shutdown request (will be handled by main loop) }, getContextUsage: () => this.ctx.session.getContextUsage(), compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.ctx.session.compact(instructions, options); }, getSystemPrompt: () => this.ctx.session.systemPrompt, }; const commandActions: ExtensionCommandContextActions = { getContextUsage: () => this.ctx.session.getContextUsage(), waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, newSession: async options => { // Stop any loading animation if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.clear(); // Create new session this.clearExtensionTerminalInputListeners(); const success = await this.ctx.session.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; } // Call setup callback if provided if (options?.setup) { await options.setup(this.ctx.sessionManager); } // Clear UI state this.ctx.chatContainer.clear(); this.ctx.pendingMessagesContainer.clear(); this.ctx.compactionQueuedMessages = []; this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; this.ctx.pendingTools.clear(); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), ); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(); return { cancelled: false }; }, branch: async entryId => { const result = await this.ctx.session.branch(entryId); if (result.cancelled) { return { cancelled: true }; } // Update UI this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); this.ctx.editor.setText(result.selectedText); this.ctx.showStatus("Branched to new session"); return { cancelled: false }; }, navigateTree: async (targetId, options) => { const result = await this.ctx.session.navigateTree(targetId, { summarize: options?.summarize }); if (result.cancelled) { return { cancelled: true }; } // Update UI this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setText(result.editorText); } this.ctx.showStatus("Navigated to selected point"); return { cancelled: false }; }, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; if (this.ctx.isBackgrounded) { await this.ctx.session.compact(instructions, options); return; } await this.ctx.executeCompaction(instructionsOrOptions, false); }, switchSession: async sessionPath => { const result = await this.ctx.session.switchSession(sessionPath); if (!result) { return { cancelled: true }; } this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); return { cancelled: false }; }, }; extensionRunner.initialize(actions, contextActions, commandActions, uiContext); // Subscribe to extension errors extensionRunner.onError((error: ExtensionError) => { this.showExtensionError(error.extensionPath, error.error); }); // Emit session_start event await extensionRunner.emit({ type: "session_start", }); } setHookWidget(key: string, content: unknown): void { this.ctx.statusLine.setHookStatus(key, String(content)); this.ctx.ui.requestRender(); } initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void { const extensionRunner = this.ctx.session.extensionRunner; if (!extensionRunner) { return; } const actions: ExtensionActions = { sendMessage: (message, options) => { const wasStreaming = this.ctx.session.isStreaming; this.ctx.session .sendCustomMessage(message, options) .then(() => { // For non-streaming cases with display=true, update UI // (streaming cases update via message_end event) if (!this.ctx.isBackgrounded && !wasStreaming && message.display) { this.ctx.rebuildChatFromMessages(); } }) .catch((err: unknown) => { const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`; if (this.ctx.isBackgrounded) { logger.error(errorText); return; } this.ctx.showError(errorText); }); }, sendUserMessage: (content, options) => { this.ctx.session.sendUserMessage(content, options).catch((err: unknown) => { this.ctx.showError( `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`, ); }); }, appendEntry: (customType, data) => { this.ctx.sessionManager.appendCustomEntry(customType, data); }, setLabel: (targetId, label) => { this.ctx.sessionManager.appendLabelChange(targetId, label); }, getActiveTools: () => this.ctx.session.getActiveToolNames(), getAllTools: () => this.ctx.session.getAllToolNames(), setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames), setModel: async model => { const key = await this.ctx.session.modelRegistry.getApiKey(model); if (!key) return false; await this.ctx.session.setModel(model); return true; }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist), getCommands: () => [], }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, isIdle: () => !this.ctx.session.isStreaming, abort: () => this.ctx.session.abort(), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, shutdown: () => { // Signal shutdown request (will be handled by main loop) }, getContextUsage: () => this.ctx.session.getContextUsage(), compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.ctx.session.compact(instructions, options); }, getSystemPrompt: () => this.ctx.session.systemPrompt, }; const commandActions: ExtensionCommandContextActions = { getContextUsage: () => this.ctx.session.getContextUsage(), waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { if (this.ctx.isBackgrounded) { return; } await this.ctx.session.reload(); this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, newSession: async options => { if (this.ctx.isBackgrounded) { return { cancelled: true }; } // Stop any loading animation if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; } this.ctx.statusContainer.clear(); // Create new session this.clearExtensionTerminalInputListeners(); const success = await this.ctx.session.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; } // Call setup callback if provided if (options?.setup) { await options.setup(this.ctx.sessionManager); } // Clear UI state this.ctx.chatContainer.clear(); this.ctx.pendingMessagesContainer.clear(); this.ctx.compactionQueuedMessages = []; this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; this.ctx.pendingTools.clear(); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), ); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(); return { cancelled: false }; }, branch: async entryId => { if (this.ctx.isBackgrounded) { return { cancelled: true }; } const result = await this.ctx.session.branch(entryId); if (result.cancelled) { return { cancelled: true }; } // Update UI this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); this.ctx.editor.setText(result.selectedText); this.ctx.showStatus("Branched to new session"); return { cancelled: false }; }, navigateTree: async (targetId, options) => { if (this.ctx.isBackgrounded) { return { cancelled: true }; } const result = await this.ctx.session.navigateTree(targetId, { summarize: options?.summarize }); if (result.cancelled) { return { cancelled: true }; } // Update UI this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setText(result.editorText); } this.ctx.showStatus("Navigated to selected point"); return { cancelled: false }; }, compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; if (this.ctx.isBackgrounded) { await this.ctx.session.compact(instructions, options); return; } await this.ctx.executeCompaction(instructionsOrOptions, false); }, switchSession: async sessionPath => { if (this.ctx.isBackgrounded) { return { cancelled: true }; } const result = await this.ctx.session.switchSession(sessionPath); if (!result) { return { cancelled: true }; } this.ctx.chatContainer.clear(); this.ctx.renderInitialMessages(); await this.ctx.reloadTodos(); return { cancelled: false }; }, }; extensionRunner.initialize(actions, contextActions, commandActions, uiContext); } createBackgroundUiContext(): ExtensionUIContext { return { select: async (_title: string, _options: string[], _dialogOptions) => undefined, confirm: async (_title: string, _message: string, _dialogOptions) => false, input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined, notify: () => {}, onTerminalInput: () => () => {}, setStatus: () => {}, setWorkingMessage: () => {}, setWidget: () => {}, setTitle: () => {}, custom: async () => undefined as never, setEditorText: () => {}, pasteToEditor: () => {}, getEditorText: () => "", editor: async () => undefined, get theme() { return theme; }, getAllThemes: () => Promise.resolve([]), getTheme: () => Promise.resolve(undefined), setTheme: () => Promise.resolve({ success: false, error: "Background mode" }), setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, getToolsExpanded: () => false, setToolsExpanded: () => {}, }; } /** * Emit session event to all extension tools. */ async emitCustomToolSessionEvent( reason: "start" | "switch" | "branch" | "tree" | "shutdown", previousSessionFile?: string, ): Promise { const event = { reason, previousSessionFile }; const uiContext = this.ctx.session.extensionRunner?.getUIContext(); if (!uiContext) { return; } for (const registeredTool of this.ctx.session.extensionRunner?.getAllRegisteredTools() ?? []) { if (registeredTool.definition.onSession) { try { await registeredTool.definition.onSession(event, { ui: uiContext, getContextUsage: () => this.ctx.session.getContextUsage(), compact: async instructionsOrOptions => { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.ctx.session.compact(instructions, options); }, hasUI: !this.ctx.isBackgrounded, cwd: this.ctx.sessionManager.getCwd(), sessionManager: this.ctx.session.sessionManager, modelRegistry: this.ctx.session.modelRegistry, model: this.ctx.session.model, isIdle: () => !this.ctx.session.isStreaming, hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, hasQueuedMessages: () => this.ctx.session.queuedMessageCount > 0, abort: () => { this.ctx.session.abort(); }, shutdown: () => { // Signal shutdown request }, getSystemPrompt: () => this.ctx.session.systemPrompt, }); } catch (err) { this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err)); } } } } /** * Show a tool error in the chat. */ showToolError(toolName: string, error: string): void { if (this.ctx.isBackgrounded) { logger.error(`Tool "${toolName}" error: ${error}`); return; } const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0); this.ctx.chatContainer.addChild(errorText); this.ctx.ui.requestRender(); } /** * Set hook status text in the footer. */ setHookStatus(key: string, text: string | undefined): void { if (this.ctx.isBackgrounded) { return; } this.ctx.statusLine.setHookStatus(key, text); this.ctx.ui.requestRender(); } /** * Show a selector for hooks. */ showHookSelector( title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions, ): Promise { const { promise, resolve } = Promise.withResolvers(); let settled = false; const onAbort = () => { this.hideHookSelector(); if (!settled) { settled = true; resolve(undefined); } }; const finish = (value: string | undefined) => { if (settled) return; settled = true; dialogOptions?.signal?.removeEventListener("abort", onAbort); resolve(value); }; this.#hookSelectorOverlay?.hide(); this.#hookSelectorOverlay = undefined; const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); this.ctx.hookSelector = new HookSelectorComponent( title, options, option => { this.hideHookSelector(); finish(option); }, () => { this.hideHookSelector(); finish(undefined); }, { initialIndex: dialogOptions?.initialIndex, timeout: dialogOptions?.timeout, tui: this.ctx.ui, outline: dialogOptions?.outline, maxVisible, }, ); this.#hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.#dialogOverlayOptions); if (dialogOptions?.signal) { if (dialogOptions.signal.aborted) { onAbort(); } else { dialogOptions.signal.addEventListener("abort", onAbort, { once: true }); } } return promise; } /** * Hide the hook selector. */ hideHookSelector(): void { this.ctx.hookSelector?.dispose(); this.#hookSelectorOverlay?.hide(); this.#hookSelectorOverlay = undefined; this.ctx.hookSelector = undefined; this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); } /** * Show a confirmation dialog for hooks. */ 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. */ showHookInput( title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions, ): Promise { const { promise, resolve } = Promise.withResolvers(); let settled = false; const onAbort = () => { this.hideHookInput(); if (!settled) { settled = true; resolve(undefined); } }; const finish = (value: string | undefined) => { if (settled) return; settled = true; dialogOptions?.signal?.removeEventListener("abort", onAbort); resolve(value); }; this.#hookInputOverlay?.hide(); this.#hookInputOverlay = undefined; this.ctx.hookInput = new HookInputComponent( title, placeholder, value => { this.hideHookInput(); finish(value); }, () => { this.hideHookInput(); finish(undefined); }, ); this.#hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.#dialogOverlayOptions); if (dialogOptions?.signal) { if (dialogOptions.signal.aborted) { onAbort(); } else { dialogOptions.signal.addEventListener("abort", onAbort, { once: true }); } } return promise; } /** * Hide the hook input. */ hideHookInput(): void { this.ctx.hookInput?.dispose(); this.#hookInputOverlay?.hide(); this.#hookInputOverlay = undefined; this.ctx.hookInput = undefined; this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); } /** * Show a multi-line editor for hooks (with Ctrl+G support). */ showHookEditor(title: string, prefill?: string): Promise { const { promise, resolve } = Promise.withResolvers(); this.ctx.hookEditor = new HookEditorComponent( this.ctx.ui, title, prefill, value => { this.hideHookEditor(); resolve(value); }, () => { this.hideHookEditor(); resolve(undefined); }, ); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.hookEditor); this.ctx.ui.setFocus(this.ctx.hookEditor); this.ctx.ui.requestRender(); return promise; } /** * Hide the hook editor. */ hideHookEditor(): void { this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.hookEditor = undefined; this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); } /** * Show a notification for hooks. */ showHookNotify(message: string, type?: "info" | "warning" | "error"): void { if (type === "error") { this.ctx.showError(message); } else if (type === "warning") { this.ctx.showWarning(message); } else { this.ctx.showStatus(message); } } /** * Show a custom component with keyboard focus. */ async showHookCustom( factory: ( tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, options?: { overlay?: boolean }, ): Promise { const savedText = this.ctx.editor.getText(); const keybindings = KeybindingsManager.inMemory(); const { promise, resolve } = Promise.withResolvers(); let component: (Component & { dispose?(): void }) | undefined; let overlayHandle: OverlayHandle | undefined; let closed = false; const close = (result: T) => { if (closed) return; closed = true; component?.dispose?.(); overlayHandle?.hide(); overlayHandle = undefined; if (!options?.overlay) { this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.editor.setText(savedText); } this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); resolve(result); }; Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then(c => { if (closed) { c.dispose?.(); return; } component = c; if (options?.overlay) { overlayHandle = this.ctx.ui.showOverlay(component, { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 0, }); return; } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(component); this.ctx.ui.setFocus(component); this.ctx.ui.requestRender(); }); return promise; } /** * Show an extension error in the UI. */ addExtensionTerminalInputListener(handler: TerminalInputHandler): () => void { const unsubscribe = this.ctx.ui.addInputListener(handler); this.#extensionTerminalInputUnsubscribers.add(unsubscribe); return () => { unsubscribe(); this.#extensionTerminalInputUnsubscribers.delete(unsubscribe); }; } clearExtensionTerminalInputListeners(): void { for (const unsubscribe of this.#extensionTerminalInputUnsubscribers) { unsubscribe(); } this.#extensionTerminalInputUnsubscribers.clear(); } showExtensionError(extensionPath: string, error: string): void { const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0); this.ctx.chatContainer.addChild(errorText); this.ctx.ui.requestRender(); } }