import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui"; import { Container, Spacer, Text } from "@oh-my-pi/pi-tui"; import type { CollabUiRequestDraft, CollabUiSelectItem } from "@oh-my-pi/pi-wire"; import type { CollabHost } from "../../collab/host"; import { formatKeyHint, formatKeyHints, KeybindingsManager } from "@oh-my-pi/pi-tui/app-keybindings"; import type { CompactOptions, ExtensionActions, ExtensionApprovalAnswer, ExtensionApprovalPrompt, ExtensionAskDialogQuestion, ExtensionAskDialogResult, ExtensionAskDialogResultItem, ExtensionCommandContextActions, ExtensionContextActions, ExtensionCustomOptions, ExtensionError, ExtensionFormDialogResult, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionUISelectItem, ExtensionUiComponent, ExtensionWidgetContent, ExtensionWidgetOptions, SendMessageHandler, SendUserMessageHandler, TerminalInputHandler, } from "../../extensibility/extensions"; import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; import { cfgExtensionAssistantTextRenderers } from "../../extensibility/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-tui/chat/assistant-message"; import { ApprovalDialogComponent } from "@oh-my-pi/pi-tui/overlays/approval-dialog"; import { type AskDialogInputGuard, type AskDialogPrompt, type AskDialogPromptDraft, type AskDialogPromptValue, AskDialogComponent, normalizeDialogQuestions, } from "@oh-my-pi/pi-tui/overlays/ask-dialog"; import { installExtensionComposerShape } from "@oh-my-pi/pi-tui/overlays/composer-shape-registry"; import { choiceValue, fieldError, fieldQuestions, type HtmlFormData, type HtmlFormValue, textValue, } from "@oh-my-pi/pi-tui/html-form/data"; import { HtmlFormDialog } from "@oh-my-pi/pi-tui/html-form/dialog"; import { type ChoiceField, type HtmlForm, parseHtmlForm, spansText, type TextField, } from "@oh-my-pi/pi-tui/html-form/parse"; import { EditorTopGap } from "@oh-my-pi/pi-tui/prompt/editor-top-gap"; import { boundPromptTitle, HookEditorComponent, type HookEditorOptions } from "@oh-my-pi/pi-tui/overlays/hook-editor"; import { HookInputComponent } from "@oh-my-pi/pi-tui/overlays/hook-input"; import { extensionLinkClicksAvailable } from "@oh-my-pi/pi-tui/render/extension-links"; import { HookSelectorComponent, type HookSelectorSlider } from "@oh-my-pi/pi-tui/overlays/hook-selector"; import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "@oh-my-pi/pi-tui/theme"; import type { InteractiveModeContext, InteractiveSelectorDialogOptions } from "../../modes/types"; import { normalizeCustomMessagePayload, USER_INTERRUPT_LABEL } from "../../session/messages"; import { disambiguateDisplayLabels, sanitizeCarriageReturns } from "@oh-my-pi/pi-tui/render/render-utils"; import { setExtensionTerminalTitle, setSessionTerminalTitle } from "../../utils/title-generator"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { launchTerminal } from "../../subprocess/terminal-launch"; import { approvalAnswerForChoice, approvalChoiceLabels, approvalOnceLabel } from "../../tools/approval"; const MAX_WIDGET_LINES = 10; /** * Footer hint for a guest-rendered ask selector. The guest's selector handles * the keys, so the host can't know its bindings: advertise the defaults. */ function guestAskHelpText(enterAction: string, extra = ""): string { return `${formatKeyHints(["up", "down"])} navigate ${formatKeyHint("enter")} ${enterAction} ${extra}${formatKeyHint("escape")} cancel`; } const ASK_OTHER_OPTION = "Other (type your own)"; const ASK_CHAT_OPTION = "Chat about this"; const ASK_NEXT_OPTION = "Next →"; function withTerminalLauncher(uiContext: ExtensionUIContext, hasUI: boolean): ExtensionUIContext { if (!hasUI || uiContext.openTerminal) return uiContext; const descriptors = Object.getOwnPropertyDescriptors(uiContext); descriptors.openTerminal = { configurable: true, enumerable: true, value: launchTerminal, writable: true, }; return Object.create(Object.getPrototypeOf(uiContext), descriptors) as ExtensionUIContext; } async function editDialogExternally(text: string): Promise { const command = getEditorCommand(); return command ? openInEditor(command, text) : null; } interface CollabWinner { source: "local" | "remote"; value: T | undefined; } /** Tagged result from a guest UI request, distinguishing a real answer (even * one whose literal value is "unavailable"), an explicit guest cancel, and a * transport-unavailable sentinel (collab teardown / abort). Replaces the old * `string | "unavailable" | undefined` channel that let a guest answer of * "unavailable" collide with the transport sentinel. */ type GuestUiResult = { kind: "answered"; value: string } | { kind: "cancelled" } | { kind: "unavailable" }; /** A guest's answer to one form field: its value (none for an unchecked choice), a cancel, or no guest. */ type GuestFieldResult = | { kind: "answered"; value: HtmlFormValue | undefined } | { kind: "cancelled" } | { kind: "unavailable" }; function toWireSelectOptions(options: ExtensionUISelectItem[]): CollabUiSelectItem[] { return options.map(option => typeof option === "string" ? option : option.description ? { label: option.label, description: option.description } : { label: option.label }, ); } export class ExtensionUiController { #extensionTerminalInputUnsubscribers = new Set<() => void>(); #composerShapeDisposers: Array<() => void> = []; #hookWidgetsAbove = new Map(); #hookWidgetsBelow = new Map(); // Single-file dialog surface (`editorContainer` + focus) is shared by the // selector / input / editor modals, so only one may be presented at a time; // the rest queue. See `#presentDialog`. #dialogActive = false; #dialogQueue: Array<() => void> = []; /** * The steering messages that last denied an approval. Steering waits for the tool batch to * end, so while any of them is still pending, later approvals of that batch (an exclusive call * asks only after the denied one settles) are denied by it as well. Tracked by identity: once * they are delivered, an equal-looking newer steer (another `[Image]` chip) predates its prompt * and does not answer it. */ #denyingSteering: readonly AgentMessage[] | undefined; /** * Built once in `initHooksAndCustomTools()`. Reused directly by `/tree` * `ask` re-answer (issue #5642) to drive a standalone `AskTool.execute()` * call with the same picker/dialog primitives a live tool call would get. */ #toolUIContext: ExtensionUIContext | undefined; constructor(private ctx: InteractiveModeContext) {} #syncExtensionComposerShapes(): void { this.disposeComposerShapes(); for (const definition of this.ctx.session.extensionRunner?.getComposerShapes() ?? []) { this.#composerShapeDisposers.push(installExtensionComposerShape(definition)); } this.ctx.syncComposerShape(); this.ctx.syncExtensionLinks(); } /** Remove extension-owned composer styles from the process registries. */ disposeComposerShapes(): void { for (const dispose of this.#composerShapeDisposers.splice(0)) dispose(); } /** * Redraw every transcript component of the reply sent at `timestamp` (one per tool-call segment), * and the link browser's copy while it is open. A plain terminal cannot repaint rows it already * moved into scrollback, so a reply with retired rows rebuilds the scrollback, as the * thinking-visibility toggle does (once the link browser closes, if it covers the screen); one * still in the live viewport (and any block in Tern's native view) just repaints. */ #rerenderAssistantMessage(timestamp: number): void { if (!cfgExtensionAssistantTextRenderers.get(this.ctx.settings)) return; this.ctx.rerenderInLinkBrowser(timestamp); this.ctx.redrawTranscriptBlocks( this.ctx.chatContainer.children, child => child instanceof AssistantMessageComponent && child.rerenderMessage(timestamp), ); } /** * Initialize the hook system with TUI-based UI context. */ async initHooksAndCustomTools(): Promise { // Create and set hook & tool UI context const uiContext: ExtensionUIContext = { timeoutStartsOnPresentation: true, supportsEditor: true, select: (title, options, dialogOptions) => this.showCollabAwareSelector(title, options, dialogOptions), confirm: (title, message, dialogOptions) => this.showHookConfirm(title, message, dialogOptions), input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions), askDialog: (questions, dialogOptions) => this.showAskDialog(questions, dialogOptions), formDialog: (html, dialogOptions) => this.showFormDialog(html, dialogOptions), approve: (prompt, dialogOptions) => this.showCollabAwareApproval(prompt, dialogOptions), notify: (message, type) => this.showHookNotify(message, type), onTerminalInput: handler => this.addExtensionTerminalInputListener(handler), rerenderMessage: timestamp => this.#rerenderAssistantMessage(timestamp), refreshTranscript: ids => this.ctx.refreshTranscriptPresentation(new Set(ids)), getLinkClicksAvailable: extensionLinkClicksAvailable, setStatus: (key, text) => this.setHookStatus(key, text), setWorkingMessage: message => this.ctx.setWorkingMessage(message), setWidget: (key, content, options) => this.setHookWidget(key, content, options), setTitle: title => setExtensionTerminalTitle(title), custom: (factory, options) => this.showHookCustom(factory, options), setEditorText: text => { this.ctx.editor.setText(text); this.ctx.ui.requestRender(); }, pasteToEditor: text => { this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`); this.ctx.ui.requestRender(); }, getEditorText: () => this.ctx.editor.getText(), editor: (title, prefill, dialogOptions, editorOptions) => this.showCollabAwareEditor(title, prefill, dialogOptions, editorOptions), addAutocompleteProvider: factory => this.ctx.addAutocompleteProvider(factory), getEditorComponent: () => this.ctx.getEditorComponent(), 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: factory => this.ctx.setEditorComponent(factory), getToolsExpanded: () => this.ctx.toolOutputExpanded, setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded), }; const enrichedUiContext = withTerminalLauncher(uiContext, true); this.ctx.setToolUIContext(enrichedUiContext, true); this.#toolUIContext = enrichedUiContext; this.ctx.session.setUsageFallbackConfirmer?.((confirmation, signal) => { const reserve = confirmation.remainingPercent === undefined ? "inside the configured reserve margin" : `${confirmation.remainingPercent.toFixed(1)}% remaining`; return this.showHookConfirm( "Coding-plan reserve reached", `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`, { signal }, ); }); const extensionRunner = this.ctx.session.extensionRunner; this.#syncExtensionComposerShapes(); if (!extensionRunner) { return; // No hooks loaded } const actions: ExtensionActions = { sendMessage: this.#sendExtensionMessage, sendUserMessage: this.#sendExtensionUserMessage, appendEntry: (customType, data) => { this.ctx.sessionManager.appendCustomEntry(customType, data); }, setLabel: (targetId, label) => { this.ctx.sessionManager.appendLabelChange(targetId, label); }, getActiveTools: () => this.ctx.session.getEnabledToolNames(), getAllTools: () => this.ctx.session.getAllToolInfos(), 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), getServiceTiers: () => this.ctx.session.serviceTierByFamily, setServiceTier: (family, tier) => this.ctx.session.setServiceTierFamily(family, tier), getCommands: () => getSessionSlashCommands(this.ctx.session), getSessionName: () => this.ctx.sessionManager.getSessionName(), setSessionName: name => this.#updateSessionName(name), }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, isIdle: () => !this.ctx.session.isStreaming, abort: () => this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL }), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, shutdown: () => this.ctx.requestShutdown(), resolvePlanReview: (reviewId, choice, input) => this.ctx.resolvePlanReview(reviewId, choice, input), getPendingPlanReview: () => this.ctx.getPendingPlanReview(), openPlanReview: () => this.ctx.openPlanReview({ fromExtension: true }), sessionModes: this.ctx.sessionModes, keybindings: this.ctx.keybindings, getContextUsage: () => this.ctx.session.getContextUsage(), compact: instructionsOrOptions => this.#compactSession(instructionsOrOptions), getSystemPrompt: () => this.ctx.session.systemPrompt, runEphemeralTurn: args => this.ctx.session.runEphemeralTurn(args), startLive: options => this.ctx.startRemoteLive(options), }; const commandActions: ExtensionCommandContextActions = { getContextUsage: () => this.ctx.session.getContextUsage(), waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, newSession: async options => { await this.ctx.prepareSessionSwitch(); this.ctx.clearTransientSessionUi(); // Create new session this.clearExtensionTerminalInputListeners(); this.clearHookWidgets(); const success = await this.ctx.session.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; } setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); // Call setup callback if provided if (options?.setup) { await options.setup(this.ctx.sessionManager); } // Reset and update status line this.ctx.statusLine.invalidate(); this.ctx.statusLine.resetActiveTime(); this.ctx.clearTransientSessionUi(); this.ctx.resetTranscript(); this.ctx.present([ new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), ]); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); return { cancelled: false }; }, branch: async entryId => { await this.ctx.prepareSessionSwitch(); const result = await this.ctx.session.branch(entryId); if (result.cancelled) { return { cancelled: true }; } // Update UI await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); 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 await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); } this.ctx.showStatus("Navigated to selected point"); return { cancelled: false }; }, compact: async instructionsOrOptions => this.#handleInteractiveCompact(instructionsOrOptions), switchSession: async sessionPath => { await this.ctx.prepareSessionSwitch(); this.clearHookWidgets(); let modelFallbackWarning: string | undefined; const result = await this.ctx.session.switchSession(sessionPath, { onModelFallback: warning => { modelFallbackWarning = warning; }, }); if (!result) { return { cancelled: true }; } setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (modelFallbackWarning) this.ctx.showWarning(modelFallbackWarning); return { cancelled: false }; }, }; extensionRunner.initialize(actions, contextActions, commandActions, enrichedUiContext, "tui"); // Subscribe to extension errors extensionRunner.onError((error: ExtensionError) => { this.showExtensionError(error.extensionPath, error.error); }); // Emit session_start event await extensionRunner.emit({ type: "session_start", }); } /** * The `ExtensionUIContext` built in `initHooksAndCustomTools()` — the same * picker/dialog primitives passed as `context.ui` for every live tool * call. `/tree` `ask` re-answer (issue #5642) reuses this to drive a * standalone `AskTool.execute()` call outside a normal agent turn. * `undefined` before hooks have initialized. */ getToolUIContext(): ExtensionUIContext | undefined { return this.#toolUIContext; } setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void { const placement = options?.placement ?? "aboveEditor"; this.#removeHookWidget(this.#hookWidgetsAbove, key); this.#removeHookWidget(this.#hookWidgetsBelow, key); if (content === undefined) { this.#rebuildHookWidgets(); return; } const target = placement === "belowEditor" ? this.#hookWidgetsBelow : this.#hookWidgetsAbove; target.set(key, this.#createHookWidget(content)); this.#rebuildHookWidgets(); } #removeHookWidget(widgets: Map, key: string): void { const existing = widgets.get(key); existing?.dispose?.(); widgets.delete(key); } #createHookWidget(content: ExtensionWidgetContent): ExtensionUiComponent { if (Array.isArray(content)) { const container = new Container(); for (const line of content.slice(0, MAX_WIDGET_LINES)) { container.addChild(new Text(line, 1, 0)); } if (content.length > MAX_WIDGET_LINES) { container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); } return container; } if (content === undefined) { throw new Error("Widget content missing"); } return content(this.ctx.ui, theme); } #rebuildHookWidgets(): void { // The margin above the editor is the top gap, or a plain spacer over hook // widgets; either way it stays in the one billed root, so installing a // widget never moves that row out of the retirement baseline. this.ctx.editorTopGapContainer.clear(); this.ctx.editorTopGapContainer.addChild( this.#hookWidgetsAbove.size === 0 ? new EditorTopGap(() => this.ctx.statusRowOccupied) : new Spacer(1), ); this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerAbove, this.#hookWidgetsAbove); this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerBelow, this.#hookWidgetsBelow); this.ctx.ui.requestRender(); } #renderHookWidgetContainer(container: Container, widgets: Map): void { container.clear(); for (const widget of widgets.values()) { container.addChild(widget); } } initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void { const extensionRunner = this.ctx.session.extensionRunner; if (!extensionRunner) { return; } const actions: ExtensionActions = { sendMessage: this.#sendExtensionMessage, sendUserMessage: this.#sendExtensionUserMessage, appendEntry: (customType, data) => { this.ctx.sessionManager.appendCustomEntry(customType, data); }, setLabel: (targetId, label) => { this.ctx.sessionManager.appendLabelChange(targetId, label); }, getActiveTools: () => this.ctx.session.getEnabledToolNames(), getAllTools: () => this.ctx.session.getAllToolInfos(), 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), getServiceTiers: () => this.ctx.session.serviceTierByFamily, setServiceTier: (family, tier) => this.ctx.session.setServiceTierFamily(family, tier), getCommands: () => getSessionSlashCommands(this.ctx.session), getSessionName: () => this.ctx.sessionManager.getSessionName(), setSessionName: name => this.#updateSessionName(name), }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, isIdle: () => !this.ctx.session.isStreaming, abort: () => this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL }), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, shutdown: () => this.ctx.requestShutdown(), resolvePlanReview: (reviewId, choice, input) => this.ctx.resolvePlanReview(reviewId, choice, input), getPendingPlanReview: () => this.ctx.getPendingPlanReview(), openPlanReview: () => this.ctx.openPlanReview({ fromExtension: true }), sessionModes: this.ctx.sessionModes, keybindings: this.ctx.keybindings, getContextUsage: () => this.ctx.session.getContextUsage(), compact: instructionsOrOptions => this.#compactSession(instructionsOrOptions), getSystemPrompt: () => this.ctx.session.systemPrompt, runEphemeralTurn: args => this.ctx.session.runEphemeralTurn(args), startLive: options => this.ctx.startRemoteLive(options), }; const commandActions: ExtensionCommandContextActions = { getContextUsage: () => this.ctx.session.getContextUsage(), waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, newSession: async options => { await this.ctx.prepareSessionSwitch(); this.ctx.clearTransientSessionUi(); // Create new session this.clearExtensionTerminalInputListeners(); this.clearHookWidgets(); 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.clearTransientSessionUi(); this.ctx.resetTranscript(); this.ctx.present([ new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), ]); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); return { cancelled: false }; }, branch: async entryId => { await this.ctx.prepareSessionSwitch(); const result = await this.ctx.session.branch(entryId); if (result.cancelled) { return { cancelled: true }; } // Update UI await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); 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 await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); } this.ctx.showStatus("Navigated to selected point"); return { cancelled: false }; }, compact: async instructionsOrOptions => this.#handleInteractiveCompact(instructionsOrOptions), switchSession: async sessionPath => { await this.ctx.prepareSessionSwitch(); this.clearHookWidgets(); let modelFallbackWarning: string | undefined; const result = await this.ctx.session.switchSession(sessionPath, { onModelFallback: warning => { modelFallbackWarning = warning; }, }); if (!result) { return { cancelled: true }; } await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (modelFallbackWarning) this.ctx.showWarning(modelFallbackWarning); return { cancelled: false }; }, }; const runnerUiContext = withTerminalLauncher(uiContext, _hasUI); extensionRunner.initialize(actions, contextActions, commandActions, runnerUiContext, "tui"); this.#syncExtensionComposerShapes(); } /** * 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; } const runner = this.ctx.session.extensionRunner; for (const registeredTool of runner?.getAllRegisteredTools() ?? []) { if (registeredTool.definition.onSession) { try { await registeredTool.definition.onSession(event, { ...runner!.createContext(), ui: uiContext, hasUI: true, compact: instructionsOrOptions => this.#compactSession(instructionsOrOptions), }); } 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 { const errorText = new Text(`Tool "${toolName}" error: ${error}`, 1, 0).setStyleFn(t => theme.fg("error", t)); this.ctx.present(errorText); } /** * Set hook status text in the footer. */ setHookStatus(key: string, text: string | undefined): void { this.ctx.statusLine.setHookStatus(key, text); this.ctx.ui.requestRender(); } async showCollabAwareSelector( title: string, options: ExtensionUISelectItem[], dialogOptions?: InteractiveSelectorDialogOptions, extra?: { slider?: HookSelectorSlider }, ): Promise { const request: CollabUiRequestDraft = { kind: "select", title, options: toWireSelectOptions(options), initialIndex: dialogOptions?.initialIndex, selectionMarker: dialogOptions?.selectionMarker, checkedIndices: dialogOptions?.checkedIndices ? [...dialogOptions.checkedIndices] : undefined, markableCount: dialogOptions?.markableCount, helpText: dialogOptions?.helpText, }; return this.#raceCollabDialog(request, dialogOptions?.signal, signal => this.showHookSelector(title, options, { ...dialogOptions, signal }, extra), ); } async showCollabAwareEditor( title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions, editorOptions?: { promptStyle?: boolean }, ): Promise { const request: CollabUiRequestDraft = { kind: "editor", title, prefill }; return this.#raceCollabDialog(request, dialogOptions?.signal, signal => this.showHookEditor(title, prefill, { ...dialogOptions, signal }, editorOptions), ); } /** * The tool-approval prompt in the composer's place; a collab guest answers it as a select over * the full prompt with the same choices ({@link approvalChoiceLabels}): the prompt's grants when * it offers any, Approve, its "for this session" choice when it offers one, and Deny. Locally it * shows the compact prompt (the details summarized to a * row, so the composer beneath keeps its room) over that list, and the expand key * swaps in the full one, wrapped — even when the two match, since a row the summary kept whole * can still clip. The composer stays mounted beneath it and takes the typing: a user message * queued as steering while the call waits (sent here, from Tern, or by a collab guest) denies * the call, so the message tells the agent why instead of a bare denial. The watch starts with * the request, so approvals still queued behind another dialog are denied by the same message. */ async showCollabAwareApproval( prompt: ExtensionApprovalPrompt, dialogOptions?: ExtensionUIDialogOptions, ): Promise { const sessionLabel = prompt.session; const onceLabel = approvalOnceLabel(prompt); const request: CollabUiRequestDraft = { kind: "select", title: prompt.full, options: approvalChoiceLabels(prompt), }; const session = this.ctx.session; const agent = session.agent; const pendingSteering = agent.peekSteeringQueue(); if (this.#denyingSteering?.some(message => pendingSteering.includes(message))) return "deny"; this.#denyingSteering = undefined; const steered = new AbortController(); let steeringBaseline = session.getQueuedMessages().steering.length; let knownSteering = new Set(pendingSteering); const unsubscribe = session.subscribe(event => { if (event.type !== "queue_update" || steered.signal.aborted) return; const current = agent.peekSteeringQueue(); if (event.steering.length > steeringBaseline) { const added = current.filter(message => !knownSteering.has(message)); this.#denyingSteering = added; // One-at-a-time delivery takes the oldest steer at the batch boundary; the // message that denied this call must be the one that lands after the denial. agent.prioritizeSteering(added); steered.abort(); } else { steeringBaseline = event.steering.length; knownSteering = new Set(current); } }); const signal = dialogOptions?.signal ? AbortSignal.any([dialogOptions.signal, steered.signal]) : steered.signal; try { const choice = await this.#raceCollabDialog(request, signal, localSignal => this.#presentDialog(localSignal, settle => { const editor = this.ctx.editor; const dialog = new ApprovalDialogComponent( prompt.compact, { onApprove: () => settle(onceLabel), onDeny: () => settle("Deny") }, { tui: this.ctx.ui, fullPrompt: prompt.full, timeout: dialogOptions?.timeout, onTimeout: dialogOptions?.onTimeout, onTimeoutStart: dialogOptions?.onTimeoutStart, onTimeoutReset: dialogOptions?.onTimeoutReset, ...(sessionLabel ? { session: { label: sessionLabel, onApprove: () => settle(sessionLabel) } } : {}), ...(prompt.grants?.length ? { grants: prompt.grants, onGrant: (grant, always) => settle(always ? grant.alwaysLabel : grant.label), } : {}), draft: { hasDraft: () => editor.getText().length > 0 || editor.pendingImages.length > 0 || editor.pasteInFlight, handleInput: keyData => editor.handleDraftEdit(keyData), }, }, ); this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(dialog); this.ctx.editorContainer.addChild(editor); this.ctx.ui.setFocus(dialog); // The dialog owns focus but forwards typing: keep the composer's cursor visible. editor.focused = true; // Its submissions steer this (main) session, even from a focused agent's view. this.ctx.approvalOwnsComposer = true; this.ctx.ui.requestRender(); return () => { this.ctx.approvalOwnsComposer = false; dialog.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(editor); this.ctx.ui.setFocus(editor); this.ctx.ui.requestRender(); }; }), ); return approvalAnswerForChoice(prompt, choice); } finally { unsubscribe(); } } async showAskDialog( questions: ExtensionAskDialogQuestion[], dialogOptions?: ExtensionUIDialogOptions, ): Promise { // Normalize the public extension input once for both race participants: // malformed entries (missing/non-string fields) coerce to empty // strings/arrays here instead of throwing inside `sanitizeCarriageReturns` // on the guest path or taking down the local render. const normalized = normalizeDialogQuestions(questions); return this.#raceGuestDialog( dialogOptions, options => this.#showLocalAskDialog(normalized, options), (host, signal) => this.#runGuestAskDialog(host, normalized, signal), ); } /** * An agent-written HTML form in the composer's place ({@link HtmlFormDialog}). A collab * guest answers it field by field — a select per choice group, an editor per text field — * and the first side to finish wins, as {@link showAskDialog}. */ async showFormDialog( html: string, dialogOptions?: ExtensionUIDialogOptions, ): Promise { const form = parseHtmlForm(html); return this.#raceGuestDialog( dialogOptions, options => this.#showLocalFormDialog(form, options), (host, signal) => this.#runGuestForm(host, form, signal), ); } /** * Race a local multi-step dialog against its guest mirror. First answer (or * cancel) wins and aborts the other side; a guest `unavailable` leaves the * local dialog running and returns its eventual result. */ async #raceGuestDialog( dialogOptions: ExtensionUIDialogOptions | undefined, local: (dialogOptions: ExtensionUIDialogOptions | undefined) => Promise, remote: (host: CollabHost, signal: AbortSignal) => Promise, ): Promise { const host = this.ctx.collabHost; if (!host) return local(dialogOptions); const localAbort = new AbortController(); const remoteAbort = new AbortController(); const parentSignal = dialogOptions?.signal; const localSignal = parentSignal ? AbortSignal.any([parentSignal, localAbort.signal]) : localAbort.signal; const remoteSignal = parentSignal ? AbortSignal.any([parentSignal, remoteAbort.signal]) : remoteAbort.signal; const localWinner = local({ ...dialogOptions, signal: localSignal }).then((value): CollabWinner => ({ source: "local", value, })); const remoteWinner: Promise> = remote(host, remoteSignal).then(result => result === "unavailable" ? localWinner : { source: "remote", value: result }, ); const winner = await Promise.race([localWinner, remoteWinner]); if (winner.source === "remote") localAbort.abort(); else remoteAbort.abort(); return winner.value; } /** * A draft in progress when a dialog lands keeps the keyboard, so in-flight * typing never answers by accident; up/down hand input to the dialog and the * draft stays parked beneath it until the dialog closes. */ #draftInputGuard(): AskDialogInputGuard | undefined { const draftEditor = this.ctx.editor; if (draftEditor.getText().length === 0) return undefined; return { isBlocked: () => draftEditor.getText().length > 0, handleInput: (keyData: string) => draftEditor.handleDraftEdit(keyData), hint: "typing edits your prompt", // Show the draft's insertion cursor while it owns input; drop it // once the dialog controls take over. syncPresentation: (blocked: boolean) => { draftEditor.focused = blocked; }, }; } #showLocalFormDialog( form: HtmlForm, dialogOptions: ExtensionUIDialogOptions | undefined, ): Promise { return this.#presentDialog(dialogOptions?.signal, settle => { const inputGuard = this.#draftInputGuard(); const dialog = new HtmlFormDialog(form, { tui: this.ctx.ui, cwd: this.ctx.sessionManager.getCwd(), timeout: dialogOptions?.timeout, onTimeout: data => { dialogOptions?.onTimeout?.(); settle({ data, timedOut: true }); }, onSubmit: data => settle({ data }), onCancel: () => settle(undefined), inputGuard, }); this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(dialog); // The guarded draft stays visible beneath the form while typing edits it. if (inputGuard) this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(dialog); this.ctx.ui.requestRender(); return () => { dialog.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); }; }); } /** * A guest's run through the form: every named field in order, each choice * group as a select (defaults preselected), each text field as an editor * prefilled with its value and re-asked until it passes the form's rules. */ async #runGuestForm( host: CollabHost, form: HtmlForm, signal: AbortSignal, ): Promise { const questions = fieldQuestions(form); const data: HtmlFormData = Object.fromEntries(form.hidden); for (const field of form.fields) { if (!field.name) continue; const question = sanitizeCarriageReturns(questions.get(field) ?? field.name); const result = field.kind === "choice" ? await this.#runGuestFormChoice(host, field, question, signal) : await this.#runGuestFormText(host, field, question, signal); if (result.kind === "unavailable") return "unavailable"; if (result.kind === "cancelled") return undefined; if (result.value !== undefined) data[field.name] = result.value; } return { data }; } async #runGuestFormChoice( host: CollabHost, field: ChoiceField, question: string, signal: AbortSignal, ): Promise { const enabled = field.options.flatMap((option, index) => (option.disabled ? [] : [index])); // Nothing a guest could pick: the group contributes nothing, as in the local form. if (enabled.length === 0 && !field.other) return { kind: "answered", value: undefined }; const checked = new Set(enabled.filter(index => field.options[index]!.checked)); const labels = disambiguateDisplayLabels( enabled.map(index => { const option = field.options[index]!; return sanitizeCarriageReturns(spansText(option.label).trim() || option.value); }), [ASK_OTHER_OPTION, ASK_NEXT_OPTION], ); // Radio and checkbox groups end with a free-text Other row. const rows = field.other ? [...labels, ASK_OTHER_OPTION] : labels; if (!field.multi) { // Several checked radios: the last wins, as in the local form. const preselected = enabled.findLastIndex(index => checked.has(index)); while (true) { const choice = await this.#requestGuestUiString( host, { kind: "select", title: question, options: rows, initialIndex: Math.max(0, preselected), selectionMarker: "radio", helpText: guestAskHelpText("select"), }, signal, ); if (choice.kind !== "answered") return choice; if (field.other && choice.value === ASK_OTHER_OPTION) { const typed = await this.#requestGuestOtherText(host, question, signal); if (typed.kind === "unavailable") return typed; // A cancelled or blank Other answer re-shows the options. if (typed.kind === "cancelled" || !typed.value.trim()) continue; return { kind: "answered", value: choiceValue(field, new Set(), typed.value) }; } const picked = enabled[labels.indexOf(choice.value)]; return { kind: "answered", value: choiceValue(field, new Set(picked === undefined ? [] : [picked])) }; } } let other: string | undefined; while (true) { // Mirror the local form's required check: Next appears once something is checked. const canSubmit = !field.required || checked.size > 0 || other !== undefined; const checkedIndices = enabled.flatMap((index, row) => (checked.has(index) ? [row] : [])); if (other !== undefined) checkedIndices.push(labels.length); const choice = await this.#requestGuestUiString( host, { kind: "select", title: question, options: canSubmit ? [...rows, ASK_NEXT_OPTION] : rows, selectionMarker: "checkbox", checkedIndices, markableCount: rows.length, helpText: guestAskHelpText("toggle", canSubmit ? `${ASK_NEXT_OPTION} continue ` : ""), }, signal, ); if (choice.kind !== "answered") return choice; if (choice.value === ASK_NEXT_OPTION) return { kind: "answered", value: choiceValue(field, checked, other) }; if (field.other && choice.value === ASK_OTHER_OPTION) { if (other !== undefined) { other = undefined; continue; } const typed = await this.#requestGuestOtherText(host, question, signal); if (typed.kind === "unavailable") return typed; // A cancelled or blank Other answer leaves Other unchecked. if (typed.kind === "answered" && typed.value.trim()) other = typed.value; continue; } const toggled = enabled[labels.indexOf(choice.value)]; if (toggled === undefined) continue; if (!checked.delete(toggled)) checked.add(toggled); } } /** A guest's free-text "Other" answer to a choice group, on one line. */ async #requestGuestOtherText(host: CollabHost, question: string, signal: AbortSignal): Promise { const input = await this.#requestGuestUiString(host, { kind: "editor", title: question }, signal); return input.kind === "answered" ? { kind: "answered", value: input.value.replace(/[\r\n]+/g, " ") } : input; } async #runGuestFormText( host: CollabHost, field: TextField, question: string, signal: AbortSignal, ): Promise { let text = field.value; let error: string | undefined; while (true) { const input = await this.#requestGuestUiString( host, { kind: "editor", title: error ? `${question} (${error})` : question, prefill: text }, signal, ); if (input.kind !== "answered") return input; // A single-line input cannot hold line breaks. text = field.multiline ? input.value : input.value.replace(/[\r\n]+/g, " "); const value = textValue(field, text); error = fieldError(field, value); if (!error) return { kind: "answered", value }; } } #showLocalAskDialog( questions: ExtensionAskDialogQuestion[], dialogOptions?: ExtensionUIDialogOptions, ): Promise { return this.#presentDialog(dialogOptions?.signal, settle => { let promptEditor: HookEditorComponent | undefined; let promptResolve: ((value: AskDialogPromptValue | AskDialogPromptDraft | undefined) => void) | undefined; let closed = false; const inputGuard = this.#draftInputGuard(); const restoreAskDialog = (): void => { if (closed || !askDialog) return; this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(askDialog); // Keep the draft editor mounted beneath the restored ask, matching the // initial presentation: the guard re-blocks whenever the draft is // non-empty (e.g. a failed submit restored its text while a nested // prompt was open), and routed input must land on a visible surface. if (inputGuard) this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(askDialog); this.ctx.ui.requestRender(); }; const finishPrompt = (value: AskDialogPromptValue | AskDialogPromptDraft | undefined): void => { const resolvePrompt = promptResolve; promptResolve = undefined; promptEditor?.dispose(); promptEditor = undefined; resolvePrompt?.(value); // Let AskDialog apply the answer and clear its prompt guard before // making the dialog visible and interactive again. This single-hop // deferral relies on #promptForCustomInput/#promptForNote clearing // #promptActive in the synchronous resume after their lone // `await this.#openPrompt(...)` (no await before the `finally`); adding one // there reopens the drop-Enter race, so revisit this deferral then. queueMicrotask(restoreAskDialog); }; const openPrompt = ( prompt: AskDialogPrompt, prefill: string | undefined, options: HookEditorOptions, ): void => { promptEditor = new HookEditorComponent( this.ctx.ui, prompt.title, prefill, (text, images) => finishPrompt({ text, images }), (text, images) => finishPrompt({ draft: { text, images } }), { promptStyle: true, externalEditor: editDialogExternally, question: prompt.question, ...options }, ); this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(promptEditor); this.ctx.ui.setFocus(promptEditor); this.ctx.ui.requestRender(); }; const promptForText = ( prompt: AskDialogPrompt, prefill?: string, ): Promise => { if (closed) return Promise.resolve(undefined); const { promise, resolve } = Promise.withResolvers(); promptResolve = value => resolve(value === undefined || "draft" in value ? value : value.text); openPrompt(prompt, prefill, {}); return promise; }; const promptWithImages = ( prompt: AskDialogPrompt, prefill: AskDialogPromptValue | undefined, ): Promise => { if (closed) return Promise.resolve(undefined); const { promise, resolve } = Promise.withResolvers< AskDialogPromptValue | AskDialogPromptDraft | undefined >(); promptResolve = resolve; openPrompt(prompt, prefill?.text, { acceptImages: true, images: prefill?.images, onPasteImage: () => this.ctx.handleImagePaste(), onPasteImagePath: path => this.ctx.handleImagePathPaste(path), }); return promise; }; const askDialog = new AskDialogComponent( questions, { onSubmit: result => settle(result), onCancel: () => settle(undefined), onPrompt: promptForText, onImagePrompt: dialogOptions?.acceptImages ? promptWithImages : undefined, }, { timeout: dialogOptions?.timeout, onTimeout: dialogOptions?.onTimeout, tui: this.ctx.ui, inputGuard, }, ); this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(askDialog); if (inputGuard) this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(askDialog); this.ctx.ui.requestRender(); return () => { closed = true; askDialog?.dispose(); promptEditor?.dispose(); promptResolve?.(undefined); promptResolve = undefined; promptEditor = undefined; this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); }; }); } /** * Race the local hook dialog against a mirrored guest ask. First *answer* * wins and cancels the other side. A remote `unavailable` settlement * (collab teardown, relay drop, abort) is NOT an answer: the local dialog * keeps running — the host user may be mid-keystroke in it — and its * eventual result is returned. */ async #raceCollabDialog( request: CollabUiRequestDraft, signal: AbortSignal | undefined, local: (signal: AbortSignal | undefined) => Promise, ): Promise { const host = this.ctx.collabHost; if (!host) return local(signal); const localAbort = new AbortController(); const remoteAbort = new AbortController(); const remote = host.requestGuestUi( request, signal ? AbortSignal.any([signal, remoteAbort.signal]) : remoteAbort.signal, { retainOnReconnect: true }, ); if (!remote) return local(signal); const localWinner = local(signal ? AbortSignal.any([signal, localAbort.signal]) : localAbort.signal).then( (value): CollabWinner => ({ source: "local", value }), ); const remoteWinner: Promise> = remote.then(result => result.kind === "answered" ? { source: "remote", value: result.value } : localWinner, ); const winner = await Promise.race([localWinner, remoteWinner]); if (winner.source === "remote") localAbort.abort(); else remoteAbort.abort(); return winner.value; } async #runGuestAskDialog( host: CollabHost, questions: ExtensionAskDialogQuestion[], signal: AbortSignal, ): Promise { const results: ExtensionAskDialogResultItem[] = []; for (const question of questions) { const result = await this.#runGuestAskQuestion(host, question, signal); if (result === "unavailable" || result === undefined) return result; if (result === "chat") return { kind: "chat" }; results.push(result); } return { kind: "submit", results }; } async #runGuestAskQuestion( host: CollabHost, question: ExtensionAskDialogQuestion, signal: AbortSignal, ): Promise { const selected = new Set(); let customInput: string | undefined; // Sanitize display copies for the guest wire (same degeneration as // the local dialog). `selected` and results keep the ORIGINAL labels // so both race winners echo identical correlation values. Display // labels are unique and sentinel-safe; the suffix maps back below. const displayLabels = disambiguateDisplayLabels( question.options.map(option => option.label), [ASK_OTHER_OPTION, ASK_CHAT_OPTION, ASK_NEXT_OPTION], ); const originalByDisplay = new Map(); question.options.forEach((option, index) => { originalByDisplay.set(displayLabels[index]!, option.label); }); // Map a guest answer (a display label, suffix included) back to the // original correlation value; unknown values pass through and are // ignored at result build, as before. const resolveGuestLabel = (value: string): string => originalByDisplay.get(value) ?? value; const displayQuestion = sanitizeCarriageReturns(question.question); const baseOptions: CollabUiSelectItem[] = question.options.map((option, index) => option.description?.trim() ? { label: displayLabels[index]!, description: sanitizeCarriageReturns(option.description.trim()) } : displayLabels[index]!, ); if (question.multi) { while (true) { const checkedIndices = question.options .map((option, index) => (selected.has(option.label) ? index : -1)) .filter(index => index >= 0); // Mirror the local dialog's Next gating: omit the Next option until // at least one option is checked or a custom answer exists, so a // guest cannot submit an empty multi-select result // (PRRT_kwDOQxs0bc6OFbDW). The remote select has no "disabled" row // concept, so we omit rather than dim it. const hasAnswer = selected.size > 0 || customInput !== undefined; const options = [...baseOptions, ASK_OTHER_OPTION]; if (hasAnswer) options.push(ASK_NEXT_OPTION); options.push(ASK_CHAT_OPTION); const choice = await this.#requestGuestUiString( host, { kind: "select", title: displayQuestion, options, selectionMarker: "checkbox", checkedIndices, markableCount: question.options.length, helpText: guestAskHelpText("toggle", hasAnswer ? "Next → continue " : ""), }, signal, ); if (choice.kind === "unavailable") return "unavailable"; if (choice.kind === "cancelled") return undefined; if (choice.value === ASK_CHAT_OPTION) return "chat"; if (choice.value === ASK_NEXT_OPTION) break; if (choice.value === ASK_OTHER_OPTION) { const input = await this.#requestGuestUiString( host, { kind: "editor", title: boundPromptTitle("Custom answer: ", displayQuestion) }, signal, ); if (input.kind === "unavailable") return "unavailable"; // Guest cancelled the Other editor: keep the ask open and // return to the option list instead of cancelling the whole ask. if (input.kind === "cancelled") continue; customInput = input.value; break; } const picked = resolveGuestLabel(choice.value); if (selected.has(picked)) selected.delete(picked); else selected.add(picked); } } else { const recommended = typeof question.recommended === "number" && Number.isInteger(question.recommended) ? question.recommended : 0; const initialIndex = Math.max(0, Math.min(recommended, Math.max(0, question.options.length - 1))); while (true) { const choice = await this.#requestGuestUiString( host, { kind: "select", title: displayQuestion, options: [...baseOptions, ASK_OTHER_OPTION, ASK_CHAT_OPTION], initialIndex, selectionMarker: "radio", markableCount: question.options.length, helpText: guestAskHelpText("select"), }, signal, ); if (choice.kind === "unavailable") return "unavailable"; if (choice.kind === "cancelled") return undefined; if (choice.value === ASK_CHAT_OPTION) return "chat"; if (choice.value === ASK_OTHER_OPTION) { const input = await this.#requestGuestUiString( host, { kind: "editor", title: boundPromptTitle("Custom answer: ", displayQuestion) }, signal, ); if (input.kind === "unavailable") return "unavailable"; // Guest cancelled the Other editor: re-show the select list // instead of cancelling the whole ask. if (input.kind === "cancelled") continue; customInput = input.value; } else { selected.add(resolveGuestLabel(choice.value)); } break; } } return { id: question.id, question: question.question, options: question.options.map(option => option.label), multi: question.multi ?? false, selectedOptions: question.options.map(option => option.label).filter(label => selected.has(label)), customInput, }; } async #requestGuestUiString( host: CollabHost, request: CollabUiRequestDraft, signal: AbortSignal, ): Promise { const remote = host.requestGuestUi(request, signal, { retainOnReconnect: true }); if (!remote) return { kind: "unavailable" }; const result = await remote; if (result.kind === "unavailable") return { kind: "unavailable" }; return typeof result.value === "string" ? { kind: "answered", value: result.value } : { kind: "cancelled" }; } /** * Show a selector for hooks. */ showHookSelector( title: string, options: ExtensionUISelectItem[], dialogOptions?: InteractiveSelectorDialogOptions, extra?: { slider?: HookSelectorSlider }, ): Promise { return this.#presentDialog(dialogOptions?.signal, settle => { const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); this.ctx.hookSelector = new HookSelectorComponent( title, options, option => settle(option), () => settle(undefined), { onLeft: dialogOptions?.onLeft ? () => { dialogOptions.onLeft?.(); settle(undefined); } : undefined, onRight: dialogOptions?.onRight ? () => { dialogOptions.onRight?.(); settle(undefined); } : undefined, onExternalEditor: dialogOptions?.onExternalEditor, helpText: dialogOptions?.helpText, initialIndex: dialogOptions?.initialIndex, timeout: dialogOptions?.timeout, onTimeout: dialogOptions?.onTimeout, onTimeoutStart: dialogOptions?.onTimeoutStart, onTimeoutReset: dialogOptions?.onTimeoutReset, tui: this.ctx.ui, outline: dialogOptions?.outline, disabledIndices: dialogOptions?.disabledIndices, selectionMarker: dialogOptions?.selectionMarker, checkedIndices: dialogOptions?.checkedIndices, markableCount: dialogOptions?.markableCount, maxVisible, inline: dialogOptions?.inline, slider: extra?.slider, }, ); this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.hookSelector); this.ctx.ui.setFocus(this.ctx.hookSelector); this.ctx.ui.requestRender(); return () => this.hideHookSelector(); }); } /** * Hide the hook selector. */ hideHookSelector(): void { this.ctx.hookSelector?.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); 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, dialogOptions?: InteractiveSelectorDialogOptions, ): Promise { const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"], dialogOptions); return result === "Yes"; } /** * Show a text input for hooks. */ showHookInput( title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions, ): Promise { return this.#presentDialog(dialogOptions?.signal, settle => { this.ctx.hookInput = new HookInputComponent( title, placeholder, value => settle(value), () => settle(undefined), { timeout: dialogOptions?.timeout, onTimeout: dialogOptions?.onTimeout, secret: dialogOptions?.secret, tui: this.ctx.ui, }, ); this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.hookInput); this.ctx.ui.setFocus(this.ctx.hookInput); this.ctx.ui.requestRender(); return () => this.hideHookInput(); }); } /** * Hide the hook input. */ hideHookInput(): void { this.ctx.hookInput?.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); 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, dialogOptions?: ExtensionUIDialogOptions, editorOptions?: { promptStyle?: boolean }, ): Promise { return this.#presentDialog(dialogOptions?.signal, settle => { this.ctx.hookEditor = new HookEditorComponent( this.ctx.ui, title, prefill, value => settle(value), () => settle(undefined), { ...editorOptions, externalEditor: editDialogExternally }, ); this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.hookEditor); this.ctx.ui.setFocus(this.ctx.hookEditor); this.ctx.ui.requestRender(); return () => this.hideHookEditor(); }); } /** * Hide the hook editor. */ hideHookEditor(): void { this.ctx.hookEditor?.dispose(); 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?: ExtensionCustomOptions, ): Promise { const savedText = this.ctx.editor.getText(); const keybindings = KeybindingsManager.inMemory(); const { promise, resolve, reject } = Promise.withResolvers(); let component: (Component & { dispose?(): void }) | undefined; let overlayHandle: OverlayHandle | undefined; let closed = false; let editorReplaced = false; const cleanup = () => { component?.dispose?.(); overlayHandle?.hide(); overlayHandle = undefined; if (editorReplaced) { 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(); }; const finish = (settle: () => void) => { if (closed) return; closed = true; options?.signal?.removeEventListener("abort", onAbort); try { cleanup(); } finally { settle(); } }; const fail = (error: unknown) => finish(() => reject(error)); const onAbort = () => fail(options?.signal?.reason ?? new DOMException("Dialog aborted", "AbortError")); const close = (result: T) => finish(() => resolve(result)); if (options?.signal?.aborted) { fail(options.signal.reason ?? new DOMException("Dialog aborted", "AbortError")); return promise; } options?.signal?.addEventListener("abort", onAbort, { once: true }); Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)) .then(c => { if (closed) { c.dispose?.(); return; } component = c; if (options?.overlay) { const overlayOptions = typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions; overlayHandle = this.ctx.ui.showOverlay( component, overlayOptions ?? { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 0, }, ); options.onHandle?.(overlayHandle); return; } editorReplaced = true; this.ctx.closeLinkBrowser(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(component); this.ctx.ui.setFocus(component); this.ctx.ui.requestRender(); }) .catch(fail); 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); }; } clearHookWidgets(): void { for (const widget of this.#hookWidgetsAbove.values()) { widget.dispose?.(); } for (const widget of this.#hookWidgetsBelow.values()) { widget.dispose?.(); } this.#hookWidgetsAbove.clear(); this.#hookWidgetsBelow.clear(); this.#rebuildHookWidgets(); } clearExtensionTerminalInputListeners(): void { for (const unsubscribe of this.#extensionTerminalInputUnsubscribers) { unsubscribe(); } this.#extensionTerminalInputUnsubscribers.clear(); } showExtensionError(extensionPath: string, error: string): void { const errorText = new Text(`Extension "${extensionPath}" error: ${error}`, 1, 0).setStyleFn(t => theme.fg("error", t), ); this.ctx.present(errorText); } async #handleInteractiveCompact(instructionsOrOptions: string | CompactOptions | undefined): Promise { await this.ctx.executeCompaction(instructionsOrOptions, false); } async #compactSession(instructionsOrOptions: string | CompactOptions | undefined): Promise { const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined; const options = instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined; await this.ctx.session.compact(instructions, options); } async #updateSessionName(name: string): Promise { await this.ctx.sessionManager.setSessionName(name, "user"); } /** * Collab guest: the replica session only mirrors the host, and mirrored host * lifecycle events (`agent_end`, `turn_end`, …) reach guest extensions. An * extension reacting to them must not start or queue a turn on the replica — * it would run on the guest's local model and diverge from the host. Refuse * like typed prompts are refused (input-controller); true when dropped. */ #rejectGuestExtensionTurn(): boolean { if (!this.ctx.collabGuest) return false; this.ctx.showStatus("Extension-initiated turns are host-only during a collab session"); return true; } #sendExtensionMessage: SendMessageHandler = (message, options) => { const startsTurn = options?.triggerTurn === true || options?.deliverAs === "steer" || options?.deliverAs === "followUp" || options?.deliverAs === "aside"; if (startsTurn && this.#rejectGuestExtensionTurn()) return; const wasStreaming = this.ctx.session.isStreaming; const messageCountBefore = this.ctx.session.messages.length; const normalized = normalizeCustomMessagePayload(message); this.ctx.session .sendCustomMessage(normalized, options) .then(() => { // `deliverAs: "nextTurn"` without a trigger is the one idle arm that appends // to the session silently (no message_start/message_end), so nothing else // paints it and nothing else in the transcript changed. const silentAppend = options?.deliverAs === "nextTurn" && options.triggerTurn !== true; this.#applyCustomMessageDisplay(wasStreaming, normalized, silentAppend ? messageCountBefore : undefined); }) .catch((err: unknown) => { this.ctx.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); }); }; /** Every `sendUserMessage` form prompts or queues a user turn (see `AgentSession.sendUserMessage`). */ #sendExtensionUserMessage: SendUserMessageHandler = (content, options) => { if (this.#rejectGuestExtensionTurn()) { const denied = Promise.reject(new Error("Extension-initiated turns are host-only during a collab session")); denied.catch(() => {}); return denied; } const task = this.ctx.session.sendUserMessage(content, options); task.catch((err: unknown) => { this.ctx.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`); }); return task; }; /** * @param silentAppendBaseline message count before a silent idle `nextTurn` append, * or `undefined` when the send was not such an append. */ #applyCustomMessageDisplay( wasStreaming: boolean, message: { customType: string; display?: boolean }, silentAppendBaseline: number | undefined, ): void { // For non-streaming cases with display=true, update UI // (streaming cases update via message_end event). // Gate on initialChatRendered (#1955): an extension's session_start // sendMessage({display:true}) runs before renderInitialMessages, which would // re-render from session entries AND re-append via preserveExistingChat, // duplicating the message. After the initial render the chat must be updated. if (wasStreaming || !message.display || !this.ctx.initialChatRendered) return; if (silentAppendBaseline !== undefined && !this.ctx.session.isStreaming) { // Append only the new message instead of replaying the whole transcript (#12183). // The count and type checks fail when the send did not land as a plain append // (e.g. it was queued behind a turn that began meanwhile), which falls through // to the rebuild below. const messages = this.ctx.session.messages; const appended = messages.at(-1); if ( messages.length === silentAppendBaseline + 1 && appended?.role === "custom" && appended.customType === message.customType ) { this.ctx.addMessageToChat(appended); this.ctx.ui.requestRender(); return; } } this.ctx.rebuildChatFromMessages(); } /** * Present a modal dialog on the shared editor surface, serializing against any * dialog already open. `present` builds the component, swaps it into * `editorContainer`, steals focus, and returns a `hide` closure; it is invoked * with a single `settle` callback that the component fires on submit/cancel. * * Because selector / input / editor all clear `editorContainer` and re-focus, * showing a second one while the first is open would orphan the first — its * promise would hang until the caller's signal aborts. So at most one dialog is * presented at a time and the rest queue (FIFO). `settle` (or an abort) hides * the current dialog and hands the surface to the next queued request. A request * whose signal aborts before its turn resolves `undefined` and is never shown. */ #presentDialog( signal: AbortSignal | undefined, present: (settle: (value: T | undefined) => void) => () => void, ): Promise { const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; let started = false; let hide: (() => void) | undefined; function onAbort(): void { settle(undefined); } const settle = (value: T | undefined): void => { if (settled) return; settled = true; signal?.removeEventListener("abort", onAbort); if (started) { hide?.(); this.#dialogActive = false; this.#advanceDialogQueue(); } resolve(value); }; const startPresentation = (): void => { if (settled) { // Aborted before its turn arrived — never present, hand off the surface. this.#advanceDialogQueue(); return; } started = true; this.#dialogActive = true; try { hide = present(settle); } catch (error) { settled = true; signal?.removeEventListener("abort", onAbort); this.#dialogActive = false; reject(error); this.#advanceDialogQueue(); } }; if (signal?.aborted) { resolve(undefined); return promise; } signal?.addEventListener("abort", onAbort, { once: true }); if (this.#dialogActive) { this.#dialogQueue.push(startPresentation); } else { startPresentation(); } return promise; } #advanceDialogQueue(): void { this.#dialogQueue.shift()?.(); } }