From c3f62ee20c89d22f32d306175f852cfdc870799a Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:46:23 -0700 Subject: [PATCH 1/4] fix(coding-agent): serialize interactive editors Give every editor-replacing surface a single cancellable owner so selectors, extension inputs, custom editors, and overlays cannot restore stale focus. Preserve exclusive owners through controlled teardown and cover the custom UI regression. --- .../src/modes/interactive/interactive-mode.ts | 666 ++++++++++++------ .../test/interactive-mode-status.test.ts | 70 +- 2 files changed, 523 insertions(+), 213 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 669476f22..ef89859b6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -57,8 +57,19 @@ import { getShareViewerUrl, VERSION, } from "../../config.ts"; -import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; -import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; +import { + type AgentSession, + type AgentSessionEvent, + type PromptOptions, + parseSkillBlock, +} from "../../core/agent-session.ts"; +import { + type AgentSessionRuntime, + SessionImportFileNotFoundError, + TaskBoundaryCancelledError, + type TaskBoundaryProposal, + type TaskBoundaryResolution, +} from "../../core/agent-session-runtime.ts"; import { CACHE_TTL_MS, type CacheMiss, @@ -80,6 +91,7 @@ import type { WorkingIndicatorOptions, } from "../../core/extensions/index.ts"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; +import { loadHarnessDescriptors } from "../../core/harness-descriptors.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; import { createCompactionSummaryMessage } from "../../core/messages.ts"; @@ -158,6 +170,7 @@ import { RetryStatusIndicator, type StatusIndicator, SubagentWaitStatusIndicator, + TaskBoundaryStatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; import { SubagentsComponent } from "./components/subagents.ts"; @@ -434,8 +447,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; @@ -445,6 +462,7 @@ export class InteractiveMode { private isInitialized = false; private onInputCallback?: (text: string) => void; private pendingUserInputs: string[] = []; + private pendingTaskPrompt: { token: object; text: string } | undefined = undefined; private activeStatusIndicator: StatusIndicator | undefined = undefined; private readonly idleStatus = new IdleStatus(); private workingMessage: string | undefined = undefined; @@ -511,9 +529,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; @@ -569,6 +584,10 @@ export class InteractiveMode { this.runtimeHost.setRebindSession(async () => { await this.rebindCurrentSession({ renderBeforeBind: true }); }); + this.runtimeHost.setProfileSwitchListener((event) => { + this.showStatus(event.notice); + }); + this.runtimeHost.setTaskBoundaryResolver((proposal) => this.resolveTaskBoundary(proposal)); this.version = VERSION; this.renderer = createInteractiveTui({ tuiMode, @@ -707,6 +726,27 @@ export class InteractiveMode { }; } + const profileCommand = slashCommands.find((command) => command.name === "profile"); + if (profileCommand) { + profileCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const profiles = this.session.resourceLoader.getProfiles().profiles; + const items = [ + { id: "auto", name: "Automatic routing", description: "Choose a profile for each distinct task" }, + ...profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + description: profile.description, + })), + ]; + return createFuzzyAutocompleteItems( + items, + prefix, + (item) => `${item.id} ${item.name} ${item.description}`, + (item) => ({ value: item.id, label: item.id, description: item.name }), + ); + }; + } + const loginCommand = slashCommands.find((command) => command.name === "login"); if (loginCommand) { loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { @@ -1072,7 +1112,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 +1122,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 +1134,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 +1142,55 @@ export class InteractiveMode { } } + private async promptUser(text: string, options?: PromptOptions): Promise { + const taskPromptToken = {}; + const showTaskRoutingProgress = + this.runtimeHost.profileRouting === "auto" && + this.runtimeHost.activeHarness !== undefined && + this.session.isIdle && + !this.isExtensionCommand(text) && + options?.streamingBehavior === undefined && + text.trim().length > 0; + let taskPromptPending = showTaskRoutingProgress; + if (showTaskRoutingProgress) { + this.pendingTaskPrompt = { token: taskPromptToken, text }; + this.updatePendingMessagesDisplay(); + this.showTaskRoutingProgress("Checking whether this starts a new task..."); + } + try { + const result = await this.runtimeHost.prompt(text, { + ...options, + preflightResult: (success) => { + options?.preflightResult?.(success); + if (!taskPromptPending || this.pendingTaskPrompt?.token !== taskPromptToken) return; + taskPromptPending = false; + this.pendingTaskPrompt = undefined; + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + }, + }); + if (result.routing === "failed") { + this.showStatus(`Profile routing unavailable; using ${result.activeProfileId}`); + } + } catch (error) { + if (!(error instanceof TaskBoundaryCancelledError)) throw error; + const restored = !this.editor.getText().trim(); + if (restored) this.editor.setText(text); + this.showStatus(restored ? "Task switch cancelled; prompt restored" : "Task switch cancelled"); + this.ui.requestRender(); + } finally { + if (taskPromptPending && this.pendingTaskPrompt?.token === taskPromptToken) { + taskPromptPending = false; + this.pendingTaskPrompt = undefined; + this.updatePendingMessagesDisplay(); + } + if (showTaskRoutingProgress) { + this.clearStatusIndicator("taskBoundary"); + this.ui.requestRender(); + } + } + } + private async checkForPackageUpdates(): Promise { if (process.env.DIFFEX_OFFLINE) { return []; @@ -1980,6 +2069,7 @@ export class InteractiveMode { this.streamingMessage = undefined; this.pendingTools.clear(); this.renderInitialMessages(); + this.updatePendingMessagesDisplay(); } /** @@ -2095,6 +2185,11 @@ export class InteractiveMode { this.ui.requestRender(); } + private showTaskRoutingProgress(message: string): void { + this.showStatusIndicator(new TaskBoundaryStatusIndicator(this.ui, message)); + this.ui.requestRender(); + } + private setWorkingIndicator(options?: WorkingIndicatorOptions): void { this.workingIndicatorOptions = options; if (this.activeStatusIndicator?.kind === "working") { @@ -2173,17 +2268,11 @@ export class InteractiveMode { this.renderWidgets(); } - private resetExtensionUI(): void { - if (this.extensionSelector) { - this.hideExtensionSelector(); - } - if (this.extensionInput) { - this.hideExtensionInput(); - } - if (this.extensionEditor) { - this.hideExtensionEditor(); + private resetExtensionUI(options: { forSessionRebind?: boolean } = {}): void { + if (!(options.forSessionRebind && this.activeEditorUi?.preserveOnSessionRebind)) { + this.cancelActiveEditorUi(); } - this.ui.hideOverlay(); + while (this.renderer.hasOverlayEntries) this.ui.hideOverlay(); this.clearExtensionTerminalInputListeners(); this.setExtensionFooter(undefined); this.setExtensionHeader(undefined); @@ -2406,6 +2495,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 +2576,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 +2624,31 @@ export class InteractiveMode { return result === "Yes"; } + private async resolveTaskBoundary(proposal: TaskBoundaryProposal): Promise { + this.clearStatusIndicator("taskBoundary"); + const activeHarness = this.runtimeHost.activeHarness; + const currentHarnessName = + activeHarness?.id === proposal.fromHarnessId ? activeHarness.name : proposal.fromHarnessId; + const targetAction = proposal.decision === "select" ? "Create" : proposal.willResumeSession ? "Resume" : "Reuse"; + const targetLabel = `${targetAction}: ${proposal.targetHarnessName} (${proposal.toProfileId})`; + const currentLabel = `Keep current: ${currentHarnessName} (${proposal.fromProfileId})`; + const selected = await this.showExtensionSelector("New task detected\nChoose a harness for this prompt", [ + targetLabel, + currentLabel, + ]); + + if (selected === targetLabel) { + const action = targetAction === "Resume" ? "Resuming" : targetAction === "Reuse" ? "Reusing" : "Creating"; + this.showTaskRoutingProgress(`${action} ${proposal.targetHarnessName}...`); + return "switch"; + } + if (selected === currentLabel) { + this.showTaskRoutingProgress(`Continuing with ${currentHarnessName}...`); + return "stay"; + } + return "cancel"; + } + private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise { const confirmed = await this.showExtensionConfirm( "Session cwd not found", @@ -2496,89 +2671,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 +2751,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 +2814,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 +2848,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 +2863,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 +2893,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 +3084,12 @@ export class InteractiveMode { await this.handleModelCommand(searchTerm); return; } + if (text === "/profile" || text.startsWith("/profile ")) { + const selection = text.startsWith("/profile ") ? text.slice(9).trim() : undefined; + this.editor.setText(""); + await this.handleProfileCommand(selection); + return; + } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -3049,7 +3225,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 +4181,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(); } @@ -4233,8 +4409,12 @@ export class InteractiveMode { private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); const { steering: steeringMessages, followUp: followUpMessages } = this.getAllQueuedMessages(); - if (steeringMessages.length > 0 || followUpMessages.length > 0) { + if (this.pendingTaskPrompt || steeringMessages.length > 0 || followUpMessages.length > 0) { this.pendingMessagesContainer.addChild(new Spacer(1)); + if (this.pendingTaskPrompt) { + const text = theme.fg("dim", `Pending: ${this.pendingTaskPrompt.text}`); + this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); + } for (const message of steeringMessages) { const text = theme.fg("dim", `Steering: ${message}`); this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); @@ -4243,9 +4423,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 +4470,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 +4478,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 +4557,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 +4564,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 { @@ -4648,6 +4814,77 @@ export class InteractiveMode { this.showModelSelector(searchTerm); } + private async handleProfileCommand(selection?: string): Promise { + if (this.session.isStreaming) { + this.showWarning("Wait for the current response to finish before changing profiles."); + return; + } + if (this.session.isCompacting) { + this.showWarning("Wait for compaction to finish before changing profiles."); + return; + } + + const activeProfile = this.runtimeHost.activeProfile; + let requested = selection; + if (!requested) { + const labels = new Map(); + const autoLabel = `auto - automatic routing${this.runtimeHost.profileRouting === "auto" ? " (active)" : ""}`; + labels.set(autoLabel, "auto"); + for (const profile of this.session.resourceLoader.getProfiles().profiles) { + const active = profile.id === activeProfile.id ? " (active)" : ""; + labels.set(`${profile.id} - ${profile.name}${active}`, profile.id); + } + const selectedLabel = await this.showExtensionSelector( + `Task profile: ${activeProfile.id} (${this.runtimeHost.profileRouting})`, + [...labels.keys()], + ); + if (!selectedLabel) return; + requested = labels.get(selectedLabel); + } + + if (!requested) return; + if (requested === "auto") { + try { + const result = await this.runtimeHost.newSession({ profile: "general", profileRouting: "auto" }); + if (result.cancelled) { + this.showStatus("Automatic profile routing cancelled"); + return; + } + this.showStatus("Automatic profile routing enabled from DEFAULT"); + } catch (error) { + this.showError( + `Could not enable automatic profile routing: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + + const target = this.session.resourceLoader.getProfiles().registry.get(requested); + if (!target) { + this.showError(`Unknown task profile: ${requested}`); + return; + } + if (target.id === activeProfile.id) { + this.runtimeHost.setProfileRouting("pinned"); + this.showStatus(`Task profile pinned: ${target.id}`); + return; + } + + try { + const result = await this.runtimeHost.newSession({ + profile: target.id, + profileRouting: "pinned", + }); + if (result.cancelled) { + this.showStatus("Task profile change cancelled"); + return; + } + this.showStatus(`Task profile pinned: ${target.id}`); + } catch (error) { + this.showError(`Could not change task profile: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async findExactModelMatch(searchTerm: string): Promise | undefined> { const cachedModels = this.session.scopedModels.length > 0 @@ -5129,6 +5366,12 @@ export class InteractiveMode { } private showSessionSelector(): void { + const harnessNames = new Map( + loadHarnessDescriptors({ agentDir: this.runtimeHost.services.agentDir }).harnesses.map((harness) => [ + harness.id, + harness.name, + ]), + ); this.showSelector((done) => { const selector = new SessionSelectorComponent( (onProgress) => @@ -5158,6 +5401,7 @@ export class InteractiveMode { }, showRenameHint: true, keybindings: this.keybindings, + getHarnessName: (harnessId) => harnessNames.get(harnessId), }, this.sessionManager.getSessionFile(), @@ -6050,7 +6294,9 @@ export class InteractiveMode { info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; } info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; - info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; + info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n`; + const activeProfile = this.runtimeHost.activeProfile; + info += `${theme.fg("dim", "Profile:")} ${activeProfile.name} (${activeProfile.id}, ${this.runtimeHost.profileRouting})\n\n`; info += `${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n`; info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; @@ -6422,7 +6668,9 @@ export class InteractiveMode { } stop(): void { - this.disposeActiveSelector(); + this.runtimeHost.setProfileSwitchListener(undefined); + this.runtimeHost.setTaskBoundaryResolver(undefined); + this.cancelActiveEditorUi(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } 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); From 18adb7fb0ccc20fb1a7d8cdf65ae70134395225a Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:48:46 -0700 Subject: [PATCH 2/4] fix(coding-agent): retain asynchronous UI ownership Route plan decisions, authentication dialogs, and share progress through cancellable editor ownership. Preserve accepted continuation handoff state so delayed asynchronous completions cannot reclaim or corrupt the active interface. --- .../src/modes/interactive/interactive-mode.ts | 397 ++++++++++++++---- .../interactive-mode-share-command.test.ts | 9 + 2 files changed, 317 insertions(+), 89 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ef89859b6..f928934db 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -60,6 +60,7 @@ import { import { type AgentSession, type AgentSessionEvent, + type PlanContinuation, type PromptOptions, parseSkillBlock, } from "../../core/agent-session.ts"; @@ -102,6 +103,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"; @@ -488,6 +490,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; @@ -579,7 +583,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 }); @@ -747,6 +752,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 => { @@ -2269,6 +2290,7 @@ export class InteractiveMode { } private resetExtensionUI(options: { forSessionRebind?: boolean } = {}): void { + this.cancelPendingPlanDecision(); if (!(options.forSessionRebind && this.activeEditorUi?.preserveOnSessionRebind)) { this.cancelActiveEditorUi(); } @@ -2649,6 +2671,189 @@ export class InteractiveMode { return "cancel"; } + 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", @@ -3090,6 +3295,12 @@ export class InteractiveMode { await this.handleProfileCommand(selection); 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(""); @@ -5770,39 +5981,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([ @@ -5812,24 +6008,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( @@ -5844,48 +6035,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")); @@ -5915,35 +6128,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( @@ -6162,30 +6375,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 { @@ -6202,9 +6420,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"; @@ -6228,8 +6446,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"}`); } } @@ -6670,6 +6888,7 @@ export class InteractiveMode { stop(): void { this.runtimeHost.setProfileSwitchListener(undefined); this.runtimeHost.setTaskBoundaryResolver(undefined); + this.cancelPendingPlanDecision(); this.cancelActiveEditorUi(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); 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, }, From bf6a34b330d662abb6fb944e7683028fb3aee08f Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sun, 16 Aug 2026 09:38:57 -0700 Subject: [PATCH 3/4] refactor(coding-agent): scope interactive prompts to plan concerns - Routing prompts through the runtime pulled the task boundary resolver, harness status indicator, and /profile command into the TUI, none of which resolve against main - Removing them leaves the exclusive prompt ownership that plan continuations depend on, keeping the interactive mode free of task profile modules --- .../src/modes/interactive/interactive-mode.ts | 209 +----------------- 1 file changed, 4 insertions(+), 205 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f928934db..b17537463 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -64,13 +64,7 @@ import { type PromptOptions, parseSkillBlock, } from "../../core/agent-session.ts"; -import { - type AgentSessionRuntime, - SessionImportFileNotFoundError, - TaskBoundaryCancelledError, - type TaskBoundaryProposal, - type TaskBoundaryResolution, -} from "../../core/agent-session-runtime.ts"; +import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; import { CACHE_TTL_MS, type CacheMiss, @@ -92,7 +86,6 @@ import type { WorkingIndicatorOptions, } from "../../core/extensions/index.ts"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; -import { loadHarnessDescriptors } from "../../core/harness-descriptors.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; import { createCompactionSummaryMessage } from "../../core/messages.ts"; @@ -172,7 +165,6 @@ import { RetryStatusIndicator, type StatusIndicator, SubagentWaitStatusIndicator, - TaskBoundaryStatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; import { SubagentsComponent } from "./components/subagents.ts"; @@ -464,7 +456,6 @@ export class InteractiveMode { private isInitialized = false; private onInputCallback?: (text: string) => void; private pendingUserInputs: string[] = []; - private pendingTaskPrompt: { token: object; text: string } | undefined = undefined; private activeStatusIndicator: StatusIndicator | undefined = undefined; private readonly idleStatus = new IdleStatus(); private workingMessage: string | undefined = undefined; @@ -589,10 +580,6 @@ export class InteractiveMode { this.runtimeHost.setRebindSession(async () => { await this.rebindCurrentSession({ renderBeforeBind: true }); }); - this.runtimeHost.setProfileSwitchListener((event) => { - this.showStatus(event.notice); - }); - this.runtimeHost.setTaskBoundaryResolver((proposal) => this.resolveTaskBoundary(proposal)); this.version = VERSION; this.renderer = createInteractiveTui({ tuiMode, @@ -731,27 +718,6 @@ export class InteractiveMode { }; } - const profileCommand = slashCommands.find((command) => command.name === "profile"); - if (profileCommand) { - profileCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { - const profiles = this.session.resourceLoader.getProfiles().profiles; - const items = [ - { id: "auto", name: "Automatic routing", description: "Choose a profile for each distinct task" }, - ...profiles.map((profile) => ({ - id: profile.id, - name: profile.name, - description: profile.description, - })), - ]; - return createFuzzyAutocompleteItems( - items, - prefix, - (item) => `${item.id} ${item.name} ${item.description}`, - (item) => ({ value: item.id, label: item.id, description: item.name }), - ); - }; - } - const planCommand = slashCommands.find((command) => command.name === "plan"); if (planCommand) { planCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { @@ -1164,52 +1130,7 @@ export class InteractiveMode { } private async promptUser(text: string, options?: PromptOptions): Promise { - const taskPromptToken = {}; - const showTaskRoutingProgress = - this.runtimeHost.profileRouting === "auto" && - this.runtimeHost.activeHarness !== undefined && - this.session.isIdle && - !this.isExtensionCommand(text) && - options?.streamingBehavior === undefined && - text.trim().length > 0; - let taskPromptPending = showTaskRoutingProgress; - if (showTaskRoutingProgress) { - this.pendingTaskPrompt = { token: taskPromptToken, text }; - this.updatePendingMessagesDisplay(); - this.showTaskRoutingProgress("Checking whether this starts a new task..."); - } - try { - const result = await this.runtimeHost.prompt(text, { - ...options, - preflightResult: (success) => { - options?.preflightResult?.(success); - if (!taskPromptPending || this.pendingTaskPrompt?.token !== taskPromptToken) return; - taskPromptPending = false; - this.pendingTaskPrompt = undefined; - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); - }, - }); - if (result.routing === "failed") { - this.showStatus(`Profile routing unavailable; using ${result.activeProfileId}`); - } - } catch (error) { - if (!(error instanceof TaskBoundaryCancelledError)) throw error; - const restored = !this.editor.getText().trim(); - if (restored) this.editor.setText(text); - this.showStatus(restored ? "Task switch cancelled; prompt restored" : "Task switch cancelled"); - this.ui.requestRender(); - } finally { - if (taskPromptPending && this.pendingTaskPrompt?.token === taskPromptToken) { - taskPromptPending = false; - this.pendingTaskPrompt = undefined; - this.updatePendingMessagesDisplay(); - } - if (showTaskRoutingProgress) { - this.clearStatusIndicator("taskBoundary"); - this.ui.requestRender(); - } - } + await this.runtimeHost.prompt(text, options); } private async checkForPackageUpdates(): Promise { @@ -2206,11 +2127,6 @@ export class InteractiveMode { this.ui.requestRender(); } - private showTaskRoutingProgress(message: string): void { - this.showStatusIndicator(new TaskBoundaryStatusIndicator(this.ui, message)); - this.ui.requestRender(); - } - private setWorkingIndicator(options?: WorkingIndicatorOptions): void { this.workingIndicatorOptions = options; if (this.activeStatusIndicator?.kind === "working") { @@ -2646,31 +2562,6 @@ export class InteractiveMode { return result === "Yes"; } - private async resolveTaskBoundary(proposal: TaskBoundaryProposal): Promise { - this.clearStatusIndicator("taskBoundary"); - const activeHarness = this.runtimeHost.activeHarness; - const currentHarnessName = - activeHarness?.id === proposal.fromHarnessId ? activeHarness.name : proposal.fromHarnessId; - const targetAction = proposal.decision === "select" ? "Create" : proposal.willResumeSession ? "Resume" : "Reuse"; - const targetLabel = `${targetAction}: ${proposal.targetHarnessName} (${proposal.toProfileId})`; - const currentLabel = `Keep current: ${currentHarnessName} (${proposal.fromProfileId})`; - const selected = await this.showExtensionSelector("New task detected\nChoose a harness for this prompt", [ - targetLabel, - currentLabel, - ]); - - if (selected === targetLabel) { - const action = targetAction === "Resume" ? "Resuming" : targetAction === "Reuse" ? "Reusing" : "Creating"; - this.showTaskRoutingProgress(`${action} ${proposal.targetHarnessName}...`); - return "switch"; - } - if (selected === currentLabel) { - this.showTaskRoutingProgress(`Continuing with ${currentHarnessName}...`); - return "stay"; - } - return "cancel"; - } - private cancelPendingPlanDecision(): void { const controller = this.planDecisionAbortController; this.planDecisionAbortController = undefined; @@ -3289,12 +3180,6 @@ export class InteractiveMode { await this.handleModelCommand(searchTerm); return; } - if (text === "/profile" || text.startsWith("/profile ")) { - const selection = text.startsWith("/profile ") ? text.slice(9).trim() : undefined; - this.editor.setText(""); - await this.handleProfileCommand(selection); - return; - } if (text === "/plan" || text.startsWith("/plan ")) { const argument = text.slice("/plan".length).trim() || undefined; this.editor.setText(""); @@ -4620,12 +4505,8 @@ export class InteractiveMode { private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); const { steering: steeringMessages, followUp: followUpMessages } = this.getAllQueuedMessages(); - if (this.pendingTaskPrompt || steeringMessages.length > 0 || followUpMessages.length > 0) { + if (steeringMessages.length > 0 || followUpMessages.length > 0) { this.pendingMessagesContainer.addChild(new Spacer(1)); - if (this.pendingTaskPrompt) { - const text = theme.fg("dim", `Pending: ${this.pendingTaskPrompt.text}`); - this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); - } for (const message of steeringMessages) { const text = theme.fg("dim", `Steering: ${message}`); this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); @@ -5025,77 +4906,6 @@ export class InteractiveMode { this.showModelSelector(searchTerm); } - private async handleProfileCommand(selection?: string): Promise { - if (this.session.isStreaming) { - this.showWarning("Wait for the current response to finish before changing profiles."); - return; - } - if (this.session.isCompacting) { - this.showWarning("Wait for compaction to finish before changing profiles."); - return; - } - - const activeProfile = this.runtimeHost.activeProfile; - let requested = selection; - if (!requested) { - const labels = new Map(); - const autoLabel = `auto - automatic routing${this.runtimeHost.profileRouting === "auto" ? " (active)" : ""}`; - labels.set(autoLabel, "auto"); - for (const profile of this.session.resourceLoader.getProfiles().profiles) { - const active = profile.id === activeProfile.id ? " (active)" : ""; - labels.set(`${profile.id} - ${profile.name}${active}`, profile.id); - } - const selectedLabel = await this.showExtensionSelector( - `Task profile: ${activeProfile.id} (${this.runtimeHost.profileRouting})`, - [...labels.keys()], - ); - if (!selectedLabel) return; - requested = labels.get(selectedLabel); - } - - if (!requested) return; - if (requested === "auto") { - try { - const result = await this.runtimeHost.newSession({ profile: "general", profileRouting: "auto" }); - if (result.cancelled) { - this.showStatus("Automatic profile routing cancelled"); - return; - } - this.showStatus("Automatic profile routing enabled from DEFAULT"); - } catch (error) { - this.showError( - `Could not enable automatic profile routing: ${error instanceof Error ? error.message : String(error)}`, - ); - } - return; - } - - const target = this.session.resourceLoader.getProfiles().registry.get(requested); - if (!target) { - this.showError(`Unknown task profile: ${requested}`); - return; - } - if (target.id === activeProfile.id) { - this.runtimeHost.setProfileRouting("pinned"); - this.showStatus(`Task profile pinned: ${target.id}`); - return; - } - - try { - const result = await this.runtimeHost.newSession({ - profile: target.id, - profileRouting: "pinned", - }); - if (result.cancelled) { - this.showStatus("Task profile change cancelled"); - return; - } - this.showStatus(`Task profile pinned: ${target.id}`); - } catch (error) { - this.showError(`Could not change task profile: ${error instanceof Error ? error.message : String(error)}`); - } - } - private async findExactModelMatch(searchTerm: string): Promise | undefined> { const cachedModels = this.session.scopedModels.length > 0 @@ -5577,12 +5387,6 @@ export class InteractiveMode { } private showSessionSelector(): void { - const harnessNames = new Map( - loadHarnessDescriptors({ agentDir: this.runtimeHost.services.agentDir }).harnesses.map((harness) => [ - harness.id, - harness.name, - ]), - ); this.showSelector((done) => { const selector = new SessionSelectorComponent( (onProgress) => @@ -5612,7 +5416,6 @@ export class InteractiveMode { }, showRenameHint: true, keybindings: this.keybindings, - getHarnessName: (harnessId) => harnessNames.get(harnessId), }, this.sessionManager.getSessionFile(), @@ -6512,9 +6315,7 @@ export class InteractiveMode { info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; } info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; - info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n`; - const activeProfile = this.runtimeHost.activeProfile; - info += `${theme.fg("dim", "Profile:")} ${activeProfile.name} (${activeProfile.id}, ${this.runtimeHost.profileRouting})\n\n`; + info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; info += `${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n`; info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; @@ -6886,8 +6687,6 @@ export class InteractiveMode { } stop(): void { - this.runtimeHost.setProfileSwitchListener(undefined); - this.runtimeHost.setTaskBoundaryResolver(undefined); this.cancelPendingPlanDecision(); this.cancelActiveEditorUi(); if (this.settingsManager.getShowTerminalProgress()) { From c92ea76c0681d5982758e6b08eca493446c41082 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sun, 16 Aug 2026 10:01:48 -0700 Subject: [PATCH 4/4] test(coding-agent): route compaction flush assertion through the runtime - Flushing the compaction queue now submits through the runtime so queued prompts serialize against plan continuations, but the test still asserted on the session double - Asserting on the runtime double keeps the test aligned with the admission path it is meant to pin --- .../coding-agent/test/interactive-mode-compaction.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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(); });