diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 669476f22..b17537463 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -57,7 +57,13 @@ import { getShareViewerUrl, VERSION, } from "../../config.ts"; -import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; +import { + type AgentSession, + type AgentSessionEvent, + type PlanContinuation, + type PromptOptions, + parseSkillBlock, +} from "../../core/agent-session.ts"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; import { CACHE_TTL_MS, @@ -90,6 +96,7 @@ import { } from "../../core/model-resolver.ts"; import { CredentialSynchronizationError } from "../../core/model-runtime.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; +import type { PlanArtifact } from "../../core/plan-mode.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts"; @@ -434,8 +441,12 @@ export class InteractiveMode { private autocompleteProviderWrappers: AutocompleteProviderFactory[] = []; private fdPath: string | undefined; private editorContainer: Container; - private activeSelectorToken?: object; - private activeSelectorDispose?: () => void; + private activeEditorUi?: { + token: object; + cancel: () => void; + exclusive: boolean; + preserveOnSessionRebind: boolean; + }; private footer: FooterComponent; private footerContainer: Container; private footerDataProvider: FooterDataProvider; @@ -470,6 +481,8 @@ export class InteractiveMode { // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); + private planDecisionAbortController: AbortController | undefined = undefined; + private suppressedPlanDecisionId: string | undefined = undefined; // Tool output expansion state private toolOutputExpanded = false; @@ -511,9 +524,6 @@ export class InteractiveMode { private shutdownRequested = false; // Extension UI state - private extensionSelector: ExtensionSelectorComponent | undefined = undefined; - private extensionInput: ExtensionInputComponent | undefined = undefined; - private extensionEditor: ExtensionEditorComponent | undefined = undefined; private extensionTerminalInputSubscriptions = new Set<{ handler: (data: string) => { consume?: boolean; data?: string } | undefined; unsubscribe: () => void; @@ -564,7 +574,8 @@ export class InteractiveMode { this.options = { ...options, tuiMode }; this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; this.runtimeHost.setBeforeSessionInvalidate(() => { - this.resetExtensionUI(); + this.suppressedPlanDecisionId = undefined; + this.resetExtensionUI({ forSessionRebind: true }); }); this.runtimeHost.setRebindSession(async () => { await this.rebindCurrentSession({ renderBeforeBind: true }); @@ -707,6 +718,22 @@ export class InteractiveMode { }; } + const planCommand = slashCommands.find((command) => command.name === "plan"); + if (planCommand) { + planCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const options = [ + { value: "on", label: "on", description: "Enable plan mode" }, + { value: "off", label: "off", description: "Disable plan mode" }, + ]; + return createFuzzyAutocompleteItems( + options, + prefix, + (option) => `${option.value} ${option.description}`, + (option) => option, + ); + }; + } + const loginCommand = slashCommands.find((command) => command.name === "login"); if (loginCommand) { loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { @@ -1072,7 +1099,7 @@ export class InteractiveMode { // Process initial messages if (initialMessage) { try { - await this.session.prompt(initialMessage, { images: initialImages }); + await this.promptUser(initialMessage, { images: initialImages }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -1082,7 +1109,7 @@ export class InteractiveMode { if (initialMessages) { for (const message of initialMessages) { try { - await this.session.prompt(message); + await this.promptUser(message); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -1094,7 +1121,7 @@ export class InteractiveMode { while (true) { const userInput = await this.getUserInput(); try { - await this.session.prompt(userInput); + await this.promptUser(userInput); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -1102,6 +1129,10 @@ export class InteractiveMode { } } + private async promptUser(text: string, options?: PromptOptions): Promise { + await this.runtimeHost.prompt(text, options); + } + private async checkForPackageUpdates(): Promise { if (process.env.DIFFEX_OFFLINE) { return []; @@ -1980,6 +2011,7 @@ export class InteractiveMode { this.streamingMessage = undefined; this.pendingTools.clear(); this.renderInitialMessages(); + this.updatePendingMessagesDisplay(); } /** @@ -2173,17 +2205,12 @@ export class InteractiveMode { this.renderWidgets(); } - private resetExtensionUI(): void { - if (this.extensionSelector) { - this.hideExtensionSelector(); + private resetExtensionUI(options: { forSessionRebind?: boolean } = {}): void { + this.cancelPendingPlanDecision(); + if (!(options.forSessionRebind && this.activeEditorUi?.preserveOnSessionRebind)) { + this.cancelActiveEditorUi(); } - if (this.extensionInput) { - this.hideExtensionInput(); - } - if (this.extensionEditor) { - this.hideExtensionEditor(); - } - this.ui.hideOverlay(); + while (this.renderer.hasOverlayEntries) this.ui.hideOverlay(); this.clearExtensionTerminalInputListeners(); this.setExtensionFooter(undefined); this.setExtensionHeader(undefined); @@ -2406,6 +2433,73 @@ export class InteractiveMode { }; } + private restoreEditorUi(): void { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + + private mountEditorUi( + component: Component, + focus: Component, + cancel: () => void, + options: { exclusive?: boolean; preserveOnSessionRebind?: boolean } = {}, + ): object { + const token = {}; + if (this.activeEditorUi?.exclusive) { + if (!this.session.isStreaming) { + cancel(); + return token; + } + this.cancelActiveEditorUi(false); + } + this.cancelActiveEditorUi(false); + this.activeEditorUi = { + token, + cancel, + exclusive: options.exclusive ?? false, + preserveOnSessionRebind: options.preserveOnSessionRebind ?? false, + }; + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(focus); + this.ui.requestRender(); + return token; + } + + private closeEditorUi(token: object): boolean { + if (this.activeEditorUi?.token !== token) return false; + this.activeEditorUi = undefined; + this.restoreEditorUi(); + return true; + } + + private replaceEditorUi(token: object, component: Component, focus: Component): boolean { + if (this.activeEditorUi?.token !== token) return false; + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(focus); + this.ui.requestRender(); + return true; + } + + private cancelActiveEditorUi(restoreEditor = true): void { + const active = this.activeEditorUi; + if (!active) return; + this.activeEditorUi = undefined; + try { + active.cancel(); + } finally { + if (restoreEditor) this.restoreEditorUi(); + } + } + + private cancelEditorUi(token: object): void { + if (this.activeEditorUi?.token !== token) return; + this.cancelActiveEditorUi(); + } + /** * Show a selector for extensions. */ @@ -2420,48 +2514,42 @@ export class InteractiveMode { return; } - const onAbort = () => { - this.hideExtensionSelector(); - resolve(undefined); - }; - opts?.signal?.addEventListener("abort", onAbort, { once: true }); - - this.extensionSelector = new ExtensionSelectorComponent( + let settled = false; + let token: object; + const selector = new ExtensionSelectorComponent( title, options, (option) => { + if (settled) return; + settled = true; opts?.signal?.removeEventListener("abort", onAbort); - this.hideExtensionSelector(); + selector.dispose(); + this.closeEditorUi(token); resolve(option); }, () => { + if (settled) return; + settled = true; opts?.signal?.removeEventListener("abort", onAbort); - this.hideExtensionSelector(); + selector.dispose(); + this.closeEditorUi(token); resolve(undefined); }, { tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() }, ); - - this.disposeActiveSelector(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.extensionSelector); - this.ui.setFocus(this.extensionSelector); - this.ui.requestRender(); + const cancel = () => { + if (settled) return; + settled = true; + opts?.signal?.removeEventListener("abort", onAbort); + selector.dispose(); + resolve(undefined); + }; + const onAbort = () => this.cancelEditorUi(token); + token = this.mountEditorUi(selector, selector, cancel); + if (!settled) opts?.signal?.addEventListener("abort", onAbort, { once: true }); }); } - /** - * Hide the extension selector. - */ - private hideExtensionSelector(): void { - this.extensionSelector?.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.extensionSelector = undefined; - this.ui.setFocus(this.editor); - this.ui.requestRender(); - } - /** * Show a confirmation dialog for extensions. */ @@ -2474,6 +2562,189 @@ export class InteractiveMode { return result === "Yes"; } + private cancelPendingPlanDecision(): void { + const controller = this.planDecisionAbortController; + this.planDecisionAbortController = undefined; + controller?.abort(); + } + + private openPendingPlanDecision(options: { explicit?: boolean } = {}): void { + const plan = this.session.pendingPlan; + if (!plan || !this.session.isIdle || this.session.isStreaming || this.planDecisionAbortController) return; + if (this.activeEditorUi || this.renderer.hasOverlayEntries) return; + if (!options.explicit && this.suppressedPlanDecisionId === plan.id) return; + + this.suppressedPlanDecisionId = undefined; + void this.showPendingPlanDecision(plan).catch((error: unknown) => { + const retryHint = + this.session.pendingPlan?.id === plan.id ? " The plan remains pending; use /plan to retry." : ""; + this.showError( + `Failed to resolve plan: ${error instanceof Error ? error.message : String(error)}${retryHint}`, + ); + }); + } + + private async startPlanContinuation(continuation: PlanContinuation, onPromptOwned?: () => void): Promise { + const handoff = new BorderedLoader(this.ui, theme, "Starting implementation...", { + cancellable: false, + }); + let handoffReleased = false; + let handoffToken: object; + const releaseHandoff = () => { + if (handoffReleased) return; + handoffReleased = true; + handoff.dispose(); + this.closeEditorUi(handoffToken); + }; + handoffToken = this.mountEditorUi( + handoff, + handoff, + () => { + if (handoffReleased) return; + handoffReleased = true; + handoff.dispose(); + }, + { exclusive: true, preserveOnSessionRebind: true }, + ); + + let preflightAccepted = false; + let inputOwnershipReleased = false; + const releaseInputOwnership = () => { + if (inputOwnershipReleased) return; + inputOwnershipReleased = true; + releaseHandoff(); + onPromptOwned?.(); + }; + try { + const prompt = this.runtimeHost.promptPlanContinuation(continuation, { + source: "interactive", + preflightResult: (success) => { + preflightAccepted = success; + if (success) releaseInputOwnership(); + }, + }); + await prompt; + if (this.session.pendingPlanContinuation) { + this.showError("Plan acceptance persisted, but implementation did not start. Use /plan to retry."); + this.ui.requestRender(); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (preflightAccepted) { + this.showError(`Plan implementation failed after it started: ${message}`); + } else { + this.showError( + `Plan acceptance persisted, but implementation could not start: ${message} Use /plan to retry.`, + ); + } + this.ui.requestRender(); + } finally { + releaseHandoff(); + } + } + + private async showPendingPlanDecision(plan: PlanArtifact): Promise { + const session = this.session; + if (session.pendingPlan?.id !== plan.id || this.planDecisionAbortController) return; + + const decisionController = new AbortController(); + const releaseDecision = () => { + if (this.planDecisionAbortController !== decisionController) return; + this.planDecisionAbortController = undefined; + }; + this.planDecisionAbortController = decisionController; + const implement = "Implement"; + const keepPlanning = "Keep planning"; + const discard = "Discard"; + + try { + const selected = await this.showExtensionSelector( + "Plan ready\nReview the submitted plan above, then choose an action", + [implement, keepPlanning, discard], + { signal: decisionController.signal }, + ); + if (decisionController.signal.aborted || this.session !== session || session.pendingPlan?.id !== plan.id) { + return; + } + + if (selected === undefined || selected === keepPlanning) { + this.suppressedPlanDecisionId = plan.id; + this.showStatus("Plan remains pending. Use /plan to reopen it."); + return; + } + + if (selected === discard) { + session.resolvePlan(plan.id, "discard"); + this.showStatus("Plan discarded; plan mode disabled"); + return; + } + + const continuation = session.resolvePlan(plan.id, "implement"); + if (!continuation) { + this.showError("Plan acceptance persisted, but no implementation prompt was returned."); + return; + } + await this.startPlanContinuation(continuation, releaseDecision); + } finally { + releaseDecision(); + } + } + + private async handlePlanCommand(argument?: string): Promise { + if (argument !== undefined && argument !== "on" && argument !== "off") { + this.showError("Usage: /plan [on|off]"); + return; + } + + const continuation = this.session.pendingPlanContinuation; + if (continuation) { + if (argument === undefined) { + await this.startPlanContinuation(continuation); + return; + } + this.showError("Accepted plan implementation is waiting. Use /plan to retry it first."); + return; + } + + if (argument === undefined && this.session.collaborationMode === "plan" && this.session.pendingPlan) { + this.openPendingPlanDecision({ explicit: true }); + return; + } + + const targetMode = + argument === "on" + ? "plan" + : argument === "off" + ? "default" + : this.session.collaborationMode === "plan" + ? "default" + : "plan"; + + try { + if (targetMode === "default" && this.session.pendingPlan) { + const plan = this.session.pendingPlan; + const confirmed = await this.showExtensionConfirm( + "Discard pending plan", + "Disable plan mode and discard the pending plan?", + ); + if (!confirmed) return; + this.session.resolvePlan(plan.id, "discard"); + this.showStatus("Plan discarded; plan mode disabled"); + return; + } + + if (this.session.collaborationMode === targetMode) { + this.showStatus(`Plan mode is already ${targetMode === "plan" ? "enabled" : "disabled"}`); + return; + } + + this.session.setCollaborationMode(targetMode); + this.showStatus(`Plan mode ${targetMode === "plan" ? "enabled" : "disabled"}`); + } catch (error: unknown) { + this.showError(`Failed to change plan mode: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise { const confirmed = await this.showExtensionConfirm( "Session cwd not found", @@ -2496,89 +2767,77 @@ export class InteractiveMode { return; } - const onAbort = () => { - this.hideExtensionInput(); - resolve(undefined); - }; - opts?.signal?.addEventListener("abort", onAbort, { once: true }); - - this.extensionInput = new ExtensionInputComponent( + let settled = false; + let token: object; + const input = new ExtensionInputComponent( title, placeholder, (value) => { + if (settled) return; + settled = true; opts?.signal?.removeEventListener("abort", onAbort); - this.hideExtensionInput(); + input.dispose(); + this.closeEditorUi(token); resolve(value); }, () => { + if (settled) return; + settled = true; opts?.signal?.removeEventListener("abort", onAbort); - this.hideExtensionInput(); + input.dispose(); + this.closeEditorUi(token); resolve(undefined); }, { tui: this.ui, timeout: opts?.timeout }, ); - - this.disposeActiveSelector(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.extensionInput); - this.ui.setFocus(this.extensionInput); - this.ui.requestRender(); + const cancel = () => { + if (settled) return; + settled = true; + opts?.signal?.removeEventListener("abort", onAbort); + input.dispose(); + resolve(undefined); + }; + const onAbort = () => this.cancelEditorUi(token); + token = this.mountEditorUi(input, input, cancel); + if (!settled) opts?.signal?.addEventListener("abort", onAbort, { once: true }); }); } - /** - * Hide the extension input. - */ - private hideExtensionInput(): void { - this.extensionInput?.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.extensionInput = undefined; - this.ui.setFocus(this.editor); - this.ui.requestRender(); - } - /** * Show a multi-line editor for extensions (with Ctrl+G support). */ private showExtensionEditor(title: string, prefill?: string): Promise { return new Promise((resolve) => { - this.extensionEditor = new ExtensionEditorComponent( + let settled = false; + let token: object; + const editor = new ExtensionEditorComponent( this.ui, this.keybindings, title, prefill, (value) => { - this.hideExtensionEditor(); + if (settled) return; + settled = true; + this.closeEditorUi(token); resolve(value); }, () => { - this.hideExtensionEditor(); + if (settled) return; + settled = true; + this.closeEditorUi(token); resolve(undefined); }, undefined, this.settingsManager.getExternalEditorCommand(), ); - - this.disposeActiveSelector(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.extensionEditor); - this.ui.setFocus(this.extensionEditor); - this.ui.requestRender(); + token = this.mountEditorUi(editor, editor, () => { + if (settled) return; + settled = true; + resolve(undefined); + }); }); } - /** - * Hide the extension editor. - */ - private hideExtensionEditor(): void { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.extensionEditor = undefined; - this.ui.setFocus(this.editor); - this.ui.requestRender(); - } - /** * Set a custom editor component from an extension. * Pass undefined to restore the default editor. @@ -2588,9 +2847,12 @@ export class InteractiveMode { // Save text from current editor before switching const currentText = this.editor.getText(); + const preserveExclusiveOwner = this.activeEditorUi?.exclusive === true; - this.disposeActiveSelector(); - this.editorContainer.clear(); + if (!preserveExclusiveOwner) { + this.cancelActiveEditorUi(false); + this.editorContainer.clear(); + } if (factory) { // Create the custom editor with tui, theme, and keybindings @@ -2648,9 +2910,11 @@ export class InteractiveMode { this.editor = this.defaultEditor; } - this.editorContainer.addChild(this.editor as Component); - this.ui.setFocus(this.editor as Component); - this.ui.requestRender(); + if (!preserveExclusiveOwner) { + this.editorContainer.addChild(this.editor as Component); + this.ui.setFocus(this.editor as Component); + this.ui.requestRender(); + } } /** @@ -2680,28 +2944,14 @@ export class InteractiveMode { onHandle?: (handle: OverlayHandle) => void; }, ): Promise { - const savedText = this.editor.getText(); const isOverlay = options?.overlay ?? false; - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.editor.setText(savedText); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; - return new Promise((resolve, reject) => { let component: Component & { dispose?(): void }; + let editorToken: object | undefined; + let overlayHandle: OverlayHandle | undefined; let closed = false; - - const close = (result: T) => { - if (closed) return; - closed = true; - if (isOverlay) this.ui.hideOverlay(); - else restoreEditor(); - // Note: both branches above already call requestRender - resolve(result); + const dispose = () => { try { component?.dispose?.(); } catch { @@ -2709,9 +2959,21 @@ export class InteractiveMode { } }; + const close = (result: T) => { + if (closed) return; + closed = true; + if (isOverlay) overlayHandle?.hide(); + else if (editorToken) this.closeEditorUi(editorToken); + resolve(result); + dispose(); + }; + Promise.resolve(factory(this.ui, theme, this.keybindings, close)) .then((c) => { - if (closed) return; + if (closed) { + c.dispose?.(); + return; + } component = c; if (isOverlay) { // Resolve overlay options - can be static or dynamic function @@ -2727,20 +2989,24 @@ export class InteractiveMode { const w = (component as { width?: number }).width; return w ? { width: w } : undefined; }; - const handle = this.ui.showOverlay(component, resolveOptions()); + overlayHandle = this.ui.showOverlay(component, resolveOptions()); // Expose handle to caller for visibility control - options?.onHandle?.(handle); + options?.onHandle?.(overlayHandle); } else { - this.disposeActiveSelector(); - this.editorContainer.clear(); - this.editorContainer.addChild(component); - this.ui.setFocus(component); - this.ui.requestRender(); + editorToken = this.mountEditorUi(component, component, () => { + if (closed) return; + closed = true; + dispose(); + reject(new Error("Interactive UI was replaced")); + }); } }) .catch((err) => { if (closed) return; - if (!isOverlay) restoreEditor(); + closed = true; + if (overlayHandle) overlayHandle.hide(); + else if (editorToken) this.closeEditorUi(editorToken); + dispose(); reject(err); }); }); @@ -2914,6 +3180,12 @@ export class InteractiveMode { await this.handleModelCommand(searchTerm); return; } + if (text === "/plan" || text.startsWith("/plan ")) { + const argument = text.slice("/plan".length).trim() || undefined; + this.editor.setText(""); + await this.handlePlanCommand(argument); + return; + } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -3049,7 +3321,7 @@ export class InteractiveMode { if (this.session.isStreaming) { this.editor.addToHistory?.(text); this.editor.setText(""); - await this.session.prompt(text, { streamingBehavior: "steer" }); + await this.promptUser(text, { streamingBehavior: "steer" }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); return; @@ -4005,7 +4277,7 @@ export class InteractiveMode { if (this.session.isStreaming) { this.editor.addToHistory?.(text); this.editor.setText(""); - await this.session.prompt(text, { streamingBehavior: "followUp" }); + await this.promptUser(text, { streamingBehavior: "followUp" }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); } @@ -4243,9 +4515,11 @@ export class InteractiveMode { const text = theme.fg("dim", `Follow-up: ${message}`); this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } - const dequeueHint = this.getAppKeyDisplay("app.message.dequeue"); - const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`); - this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); + if (steeringMessages.length > 0 || followUpMessages.length > 0) { + const dequeueHint = this.getAppKeyDisplay("app.message.dequeue"); + const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`); + this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); + } } } @@ -4288,7 +4562,7 @@ export class InteractiveMode { return !!extensionRunner.getCommand(commandName); } - private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise { + private async flushCompactionQueue(_options?: { willRetry?: boolean }): Promise { if (this.compactionQueuedMessages.length === 0) { return; } @@ -4296,75 +4570,70 @@ export class InteractiveMode { const queuedMessages = [...this.compactionQueuedMessages]; this.compactionQueuedMessages = []; this.updatePendingMessagesDisplay(); + let queueRestored = false; - const restoreQueue = (error: unknown) => { - this.session.clearQueue(); - this.compactionQueuedMessages = queuedMessages; + const restoreQueueFrom = (index: number, error: unknown) => { + if (queueRestored) return; + queueRestored = true; + const remaining = queuedMessages.slice(index); + this.compactionQueuedMessages = [...remaining, ...this.compactionQueuedMessages]; this.updatePendingMessagesDisplay(); this.showError( - `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${ + `Failed to send queued message${remaining.length > 1 ? "s" : ""}: ${ error instanceof Error ? error.message : String(error) }`, ); }; + const reportAcceptedFailure = (error: unknown) => { + this.showError( + `Queued message failed after it was accepted: ${error instanceof Error ? error.message : String(error)}`, + ); + }; - try { - if (options?.willRetry) { - // When retry is pending, queue messages for the retry turn - for (const message of queuedMessages) { - if (this.isExtensionCommand(message.text)) { - await this.session.prompt(message.text); - } else if (message.mode === "followUp") { - await this.session.followUp(message.text); - } else { - await this.session.steer(message.text); - } - } - this.updatePendingMessagesDisplay(); - return; - } - - // Find first non-extension-command message to use as prompt - const firstPromptIndex = queuedMessages.findIndex((message) => !this.isExtensionCommand(message.text)); - if (firstPromptIndex === -1) { - // All extension commands - execute them all - for (const message of queuedMessages) { + for (let index = 0; index < queuedMessages.length; index++) { + const message = queuedMessages[index]; + try { + if (this.isExtensionCommand(message.text)) { await this.session.prompt(message.text); + continue; } - return; - } - // Execute any extension commands before the first prompt - const preCommands = queuedMessages.slice(0, firstPromptIndex); - const firstPrompt = queuedMessages[firstPromptIndex]; - const rest = queuedMessages.slice(firstPromptIndex + 1); - - for (const message of preCommands) { - await this.session.prompt(message.text); - } - - // Start a prompt when idle, or queue it into a run still finishing compaction. - const promptPromise = this.session - .prompt(firstPrompt.text, { streamingBehavior: firstPrompt.mode }) - .catch((error) => { - restoreQueue(error); + let resolvePreflight: (didSucceed: boolean) => void = () => {}; + let preflightResolved = false; + let preflightAccepted = false; + const preflight = new Promise((resolve) => { + resolvePreflight = (didSucceed) => { + if (preflightResolved) return; + preflightResolved = true; + preflightAccepted = didSucceed; + resolve(didSucceed); + }; }); - - // Queue remaining messages - for (const message of rest) { - if (this.isExtensionCommand(message.text)) { - await this.session.prompt(message.text); - } else if (message.mode === "followUp") { - await this.session.followUp(message.text); - } else { - await this.session.steer(message.text); + const promptPromise = this.runtimeHost + .prompt(message.text, { + streamingBehavior: message.mode, + preflightResult: resolvePreflight, + }) + .then(() => resolvePreflight(true)) + .catch((error) => { + resolvePreflight(false); + if (preflightAccepted) { + reportAcceptedFailure(error); + } else { + restoreQueueFrom(index, error); + } + }); + if (!(await preflight)) { + await promptPromise; + return; } + void promptPromise; + } catch (error) { + restoreQueueFrom(index, error); + return; } - this.updatePendingMessagesDisplay(); - void promptPromise; - } catch (error) { - restoreQueue(error); } + this.updatePendingMessagesDisplay(); } /** Move pending bash components from pending area to chat */ @@ -4380,13 +4649,6 @@ export class InteractiveMode { // Selectors // ========================================================================= - private disposeActiveSelector(): void { - const dispose = this.activeSelectorDispose; - this.activeSelectorToken = undefined; - this.activeSelectorDispose = undefined; - dispose?.(); - } - /** * Shows a selector component in place of the editor. * @param create Factory that receives a `done` callback and returns the component and focus target @@ -4394,26 +4656,22 @@ export class InteractiveMode { private showSelector( create: (done: () => void) => { component: Component; focus: Component; dispose?: () => void }, ): void { - const token = {}; + let token: object; let dispose: (() => void) | undefined; + let closed = false; const done = () => { + if (closed) return; + closed = true; dispose?.(); - if (this.activeSelectorToken !== token) return; - this.activeSelectorToken = undefined; - this.activeSelectorDispose = undefined; - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); + this.closeEditorUi(token); }; const created = create(done); dispose = created.dispose; - this.disposeActiveSelector(); - this.activeSelectorToken = token; - this.activeSelectorDispose = dispose; - this.editorContainer.clear(); - this.editorContainer.addChild(created.component); - this.ui.setFocus(created.focus); - this.ui.requestRender(); + token = this.mountEditorUi(created.component, created.focus, () => { + if (closed) return; + closed = true; + dispose?.(); + }); } private showSettingsSelector(): void { @@ -5526,39 +5784,24 @@ export class InteractiveMode { } private showAmbientAuthDialog(providerOption: AuthSelectorProvider): void { - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; - + let token: object; const dialog = new LoginDialogComponent( this.ui, providerOption.id, - () => restoreEditor(), + () => this.closeEditorUi(token), providerOption.name, `${providerOption.name} setup`, ); dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside Diffex.`, [], true); - - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); + token = this.mountEditorUi(dialog, dialog, () => {}); } private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise { + const session = this.session; const previousModel = this.session.model; - - const dialog = new LoginDialogComponent( - this.ui, - providerId, - (_success, _message) => { - // Completion handled below - }, - providerName, - ); + const ownershipController = new AbortController(); + let token: object; + const dialog = new LoginDialogComponent(this.ui, providerId, () => this.closeEditorUi(token), providerName); if (providerId === "amazon-bedrock") { dialog.showDetails([ @@ -5568,24 +5811,19 @@ export class InteractiveMode { ]); } - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); - - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; + token = this.mountEditorUi(dialog, dialog, () => ownershipController.abort()); try { - await this.loginProvider(dialog, providerId, "api_key"); - restoreEditor(); + await this.loginProvider(dialog, providerId, "api_key", token, ownershipController.signal); + if (ownershipController.signal.aborted || this.session !== session) { + this.closeEditorUi(token); + return; + } await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); + this.closeEditorUi(token); } catch (error: unknown) { - restoreEditor(); + this.closeEditorUi(token); + if (ownershipController.signal.aborted) return; const errorMsg = error instanceof Error ? error.message : String(error); if (error instanceof CredentialSynchronizationError) { this.showError( @@ -5600,48 +5838,70 @@ export class InteractiveMode { private showAuthSelect( dialog: LoginDialogComponent, prompt: Extract, + editorToken: object, + ownershipSignal: AbortSignal, ): Promise { return new Promise((resolve, reject) => { - const restoreDialog = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); - }; + let settled = false; const labels = prompt.options.map((option) => option.label); const selector = new ExtensionSelectorComponent( prompt.message, labels, (optionLabel) => { - restoreDialog(); + if (settled) return; + settled = true; + ownershipSignal.removeEventListener("abort", onAbort); + selector.dispose(); + if (!this.replaceEditorUi(editorToken, dialog, dialog)) { + reject(new Error("Login cancelled")); + return; + } const id = prompt.options.find((option) => option.label === optionLabel)?.id; if (id) resolve(id); else reject(new Error("Login cancelled")); }, () => { - restoreDialog(); + if (settled) return; + settled = true; + ownershipSignal.removeEventListener("abort", onAbort); + selector.dispose(); + this.replaceEditorUi(editorToken, dialog, dialog); reject(new Error("Login cancelled")); }, ); - this.editorContainer.clear(); - this.editorContainer.addChild(selector); - this.ui.setFocus(selector); - this.ui.requestRender(); + const onAbort = () => { + if (settled) return; + settled = true; + selector.dispose(); + reject(new Error("Login cancelled")); + }; + if (ownershipSignal.aborted) { + onAbort(); + return; + } + ownershipSignal.addEventListener("abort", onAbort, { once: true }); + if (!this.replaceEditorUi(editorToken, selector, selector)) onAbort(); }); } - private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt): Promise { + private async showAuthPrompt( + dialog: LoginDialogComponent, + prompt: AuthPrompt, + editorToken: object, + ownershipSignal: AbortSignal, + ): Promise { let response: Promise; if (prompt.type === "select") { - response = this.showAuthSelect(dialog, prompt); + response = this.showAuthSelect(dialog, prompt, editorToken, ownershipSignal); } else if (prompt.type === "manual_code") { response = dialog.showManualInput(prompt.message); } else { response = dialog.showPrompt(prompt.message, prompt.placeholder); } - if (!prompt.signal) return response; - if (prompt.signal.aborted) throw new Error("Login cancelled"); - const signal = prompt.signal; + const signal = AbortSignal.any( + prompt.signal ? [dialog.signal, ownershipSignal, prompt.signal] : [dialog.signal, ownershipSignal], + ); + if (signal.aborted) throw new Error("Login cancelled"); let onAbort: (() => void) | undefined; const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(new Error("Login cancelled")); @@ -5671,35 +5931,35 @@ export class InteractiveMode { dialog: LoginDialogComponent, providerId: string, method: "api_key" | "oauth", + editorToken: object, + ownershipSignal: AbortSignal, ): Promise { await this.session.modelRuntime.login(providerId, method, { - signal: dialog.signal, - prompt: (prompt) => this.showAuthPrompt(dialog, prompt), + signal: AbortSignal.any([dialog.signal, ownershipSignal]), + prompt: (prompt) => this.showAuthPrompt(dialog, prompt, editorToken, ownershipSignal), notify: (event) => this.notifyAuthDialog(dialog, event), }); } private async showLoginDialog(providerId: string, providerName: string): Promise { + const session = this.session; const previousModel = this.session.model; - const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName); - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); - - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; + const ownershipController = new AbortController(); + let token: object; + const dialog = new LoginDialogComponent(this.ui, providerId, () => this.closeEditorUi(token), providerName); + token = this.mountEditorUi(dialog, dialog, () => ownershipController.abort()); try { - await this.loginProvider(dialog, providerId, "oauth"); - restoreEditor(); + await this.loginProvider(dialog, providerId, "oauth", token, ownershipController.signal); + if (ownershipController.signal.aborted || this.session !== session) { + this.closeEditorUi(token); + return; + } await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); + this.closeEditorUi(token); } catch (error: unknown) { - restoreEditor(); + this.closeEditorUi(token); + if (ownershipController.signal.aborted) return; const errorMsg = error instanceof Error ? error.message : String(error); if (error instanceof CredentialSynchronizationError) { this.showError( @@ -5918,30 +6178,35 @@ export class InteractiveMode { // Show cancellable loader, replacing the editor const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - - const restoreEditor = () => { + let proc: ReturnType | null = null; + let cancelled = false; + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; loader.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); try { fs.unlinkSync(tmpFile); } catch { // Ignore cleanup errors } }; - - // Create a secret gist asynchronously - let proc: ReturnType | null = null; + const loaderToken = this.mountEditorUi(loader, loader, () => { + cancelled = true; + proc?.kill(); + cleanup(); + }); + if (cancelled) return; + const closeLoader = () => { + const closed = this.closeEditorUi(loaderToken); + cleanup(); + return closed; + }; loader.onAbort = () => { + cancelled = true; proc?.kill(); - restoreEditor(); - this.showStatus("Share cancelled"); + if (closeLoader()) this.showStatus("Share cancelled"); }; try { @@ -5958,9 +6223,9 @@ export class InteractiveMode { proc.on("close", (code) => resolve({ stdout, stderr, code })); }); - if (loader.signal.aborted) return; + if (cancelled || loader.signal.aborted) return; - restoreEditor(); + closeLoader(); if (result.code !== 0) { const errorMsg = result.stderr?.trim() || "Unknown error"; @@ -5984,8 +6249,8 @@ export class InteractiveMode { previewUrl === directGistUrl ? `Gist: ${gistUrl}` : `Share URL: ${previewUrl}\nGist: ${gistUrl}`, ); } catch (error: unknown) { - if (!loader.signal.aborted) { - restoreEditor(); + if (!cancelled && !loader.signal.aborted) { + closeLoader(); this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); } } @@ -6422,7 +6687,8 @@ export class InteractiveMode { } stop(): void { - this.disposeActiveSelector(); + this.cancelPendingPlanDecision(); + this.cancelActiveEditorUi(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index dd019f8f4..d6621dabd 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -66,6 +66,9 @@ describe("InteractiveMode compaction events", () => { steer: vi.fn().mockResolvedValue(undefined), followUp: vi.fn().mockResolvedValue(undefined), }, + runtimeHost: { + prompt: vi.fn().mockResolvedValue(undefined), + }, isExtensionCommand: vi.fn().mockReturnValue(false), updatePendingMessagesDisplay: vi.fn(), showError: vi.fn(), @@ -78,7 +81,10 @@ describe("InteractiveMode compaction events", () => { await flushCompactionQueue.call(fakeThis, { willRetry: false }); - expect(fakeThis.session.prompt).toHaveBeenCalledWith("change direction", { streamingBehavior: "steer" }); + expect(fakeThis.runtimeHost.prompt).toHaveBeenCalledWith( + "change direction", + expect.objectContaining({ streamingBehavior: "steer" }), + ); expect(fakeThis.compactionQueuedMessages).toEqual([]); expect(fakeThis.showError).not.toHaveBeenCalled(); }); diff --git a/packages/coding-agent/test/interactive-mode-share-command.test.ts b/packages/coding-agent/test/interactive-mode-share-command.test.ts index 83b118a26..e75eb1bfc 100644 --- a/packages/coding-agent/test/interactive-mode-share-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-share-command.test.ts @@ -23,6 +23,13 @@ type ShareCommandContext = { editorContainer: { clear: () => void; addChild: (child: unknown) => void }; editor: object; ui: { setFocus: (component: unknown) => void; requestRender: () => void }; + mountEditorUi: ( + component: unknown, + focusTarget: unknown, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, + ) => object; + closeEditorUi: (token: object) => boolean; showError: (message: string) => void; showStatus: (message: string) => void; }; @@ -63,6 +70,8 @@ function createContext(): { editorContainer: { clear: vi.fn(), addChild: vi.fn() }, editor: {}, ui: { setFocus: vi.fn(), requestRender: vi.fn() }, + mountEditorUi: vi.fn(() => ({})), + closeEditorUi: vi.fn(() => true), showError, showStatus, }, diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 1c6494345..0bd173a6d 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -51,6 +51,52 @@ class TestFocusableComponent implements Component, Focusable { invalidate(): void {} } +interface EditorUiTestContext { + activeEditorUi?: { + token: object; + cancel: () => void; + exclusive: boolean; + preserveOnSessionRebind: boolean; + }; + session?: { isStreaming: boolean }; + editorContainer: Container; + editor: Component; + ui: TUI; + restoreEditorUi: () => void; + cancelActiveEditorUi: (restoreEditor?: boolean) => void; + closeEditorUi: (token: object) => boolean; + mountEditorUi: ( + component: Component, + focus: Component, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, + ) => object; +} + +const restoreEditorUi = Reflect.get(InteractiveMode.prototype, "restoreEditorUi") as ( + this: EditorUiTestContext, +) => void; +const cancelActiveEditorUi = Reflect.get(InteractiveMode.prototype, "cancelActiveEditorUi") as ( + this: EditorUiTestContext, + restoreEditor?: boolean, +) => void; +const closeEditorUi = Reflect.get(InteractiveMode.prototype, "closeEditorUi") as ( + this: EditorUiTestContext, + token: object, +) => boolean; +const mountEditorUi = Reflect.get(InteractiveMode.prototype, "mountEditorUi") as ( + this: EditorUiTestContext, + component: Component, + focus: Component, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, +) => object; +const showExtensionCustomMethod = Reflect.get(InteractiveMode.prototype, "showExtensionCustom") as ( + this: EditorUiTestContext & { keybindings: unknown }, + factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component, + options?: { overlay?: boolean }, +) => Promise; + async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise { tui.requestRender(true); await Promise.resolve(); @@ -312,18 +358,34 @@ describe("InteractiveMode.showExtensionCustom", () => { let closeReplacement: (value: string) => void = () => { throw new Error("closeReplacement was not initialized"); }; - const fakeThis = { + const fakeThis: EditorUiTestContext & { keybindings: unknown } = { editor, editorContainer, keybindings: {}, ui, - disposeActiveSelector: vi.fn(), + activeEditorUi: undefined, + restoreEditorUi(): void { + restoreEditorUi.call(fakeThis); + }, + cancelActiveEditorUi(restoreEditor?: boolean): void { + cancelActiveEditorUi.call(fakeThis, restoreEditor); + }, + closeEditorUi(token: object): boolean { + return closeEditorUi.call(fakeThis, token); + }, + mountEditorUi( + component: Component, + focus: Component, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, + ): object { + return mountEditorUi.call(fakeThis, component, focus, cancel, options); + }, }; const showExtensionCustom = ( factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component, options?: { overlay?: boolean }, - ): Promise => - (InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise; + ): Promise => showExtensionCustomMethod.call(fakeThis, factory, options) as Promise; editorContainer.addChild(editor); ui.addChild(editorContainer);