From 76bd15ae9ebb59109ff80c63f545677330d5a316 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:46:23 -0700 Subject: [PATCH 1/3] 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 a84b6e8bebae2250326550d3b9afc5a9f4b440b2 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:48:46 -0700 Subject: [PATCH 2/3] 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 6e0ab8e6d1f93f99b7ccab87ae2100d867a3bb4e Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:49:18 -0700 Subject: [PATCH 3/3] feat(coding-agent): present native plan decisions Render submitted plans as transcript artifacts, expose plan state in footer and tree views, and reopen decisions only at safe interactive lifecycle boundaries. Preserve startup prompts and restored continuations across navigation and session rebinds. --- .../modes/interactive/components/footer.ts | 3 +- .../interactive/components/plan-message.ts | 46 +++++++ .../interactive/components/tree-selector.ts | 29 +++++ .../src/modes/interactive/interactive-mode.ts | 114 +++++++++++++++--- .../coding-agent/test/footer-width.test.ts | 16 +++ .../interactive-mode-startup-input.test.ts | 35 ++++++ .../5943-session-start-notify.test.ts | 8 ++ ...sion-rebind-duplicate-subscription.test.ts | 2 + .../coding-agent/test/tree-selector.test.ts | 95 +++++++++++++++ 9 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/plan-message.ts diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 53fd512ab..16cb8347b 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -98,7 +98,8 @@ export class FooterComponent implements Component { const modelName = state.model?.id || "no-model"; const thinkingLevel = state.thinkingLevel || "off"; const windowDisplay = `${formatTokens(contextWindow)} window`; - let statsLine = `${modelName} ${thinkingLevel} • ${contextPercentStr} • ${windowDisplay}`; + const planStatus = this.session.collaborationMode === "plan" ? "[plan] " : ""; + let statsLine = `${planStatus}${modelName} ${thinkingLevel} • ${contextPercentStr} • ${windowDisplay}`; if (visibleWidth(statsLine) > width) { statsLine = truncateToWidth(statsLine, width, "..."); diff --git a/packages/coding-agent/src/modes/interactive/components/plan-message.ts b/packages/coding-agent/src/modes/interactive/components/plan-message.ts new file mode 100644 index 000000000..861f58342 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/plan-message.ts @@ -0,0 +1,46 @@ +import { + Box, + Container, + Markdown, + type MarkdownTheme, + Spacer, + stripTerminalSequences, + Text, +} from "@earendil-works/diffex-tui"; +import type { MarkdownTransformer } from "../../../core/extensions/types.ts"; +import type { PlanArtifact } from "../../../core/plan-mode.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; +import { createMarkdownTransform } from "./markdown-transform.ts"; + +/** Renders a submitted plan as a first-class transcript artifact. */ +export class PlanMessageComponent extends Container { + constructor( + plan: PlanArtifact, + markdownTheme: MarkdownTheme = getMarkdownTheme(), + outputPad = 1, + markdownTransformers: readonly MarkdownTransformer[] = [], + ) { + super(); + this.addChild(new Spacer(1)); + + const box = new Box(outputPad, 1, (content) => theme.bg("customMessageBg", content)); + const title = stripTerminalSequences(plan.title).replace(/\s+/gu, " ").trim() || "Untitled plan"; + box.addChild(new Text(theme.bold(theme.fg("customMessageLabel", `Plan: ${title}`)), 0, 0)); + box.addChild(new Spacer(1)); + box.addChild( + new Markdown( + plan.markdown, + 0, + 0, + markdownTheme, + { color: (content) => theme.fg("customMessageText", content) }, + { + preserveOrderedListMarkers: true, + preserveBackslashEscapes: true, + transform: createMarkdownTransform("assistant", false, markdownTransformers), + }, + ), + ); + this.addChild(box); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 46703c4fb..ebc8c0b39 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -362,6 +362,8 @@ class TreeList implements Component { entry.type === "custom" || entry.type === "model_change" || entry.type === "thinking_level_change" || + entry.type === "collaboration_mode_change" || + entry.type === "plan_resolution" || entry.type === "session_info"; switch (this.filterMode) { @@ -605,6 +607,15 @@ class TreeList implements Component { case "thinking_level_change": parts.push("thinking", entry.thinkingLevel); break; + case "collaboration_mode_change": + parts.push("collaboration mode", entry.mode); + break; + case "plan": + parts.push("plan", entry.plan.title, entry.plan.markdown); + break; + case "plan_resolution": + parts.push("plan resolution", entry.resolution, entry.planId); + break; case "custom": parts.push("custom", entry.customType); break; @@ -835,6 +846,15 @@ class TreeList implements Component { case "thinking_level_change": result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`); break; + case "collaboration_mode_change": + result = theme.fg("dim", `[collaboration mode: ${entry.mode}]`); + break; + case "plan": + result = theme.fg("accent", "[plan]: ") + normalize(entry.plan.title); + break; + case "plan_resolution": + result = theme.fg("dim", `[plan resolution: ${entry.resolution}]`); + break; case "custom": result = theme.fg("dim", `[custom: ${entry.customType}]`); break; @@ -919,6 +939,15 @@ class TreeList implements Component { case "branch_summary": text = entry.summary; break; + case "collaboration_mode_change": + text = `Collaboration mode: ${entry.mode}`; + break; + case "plan": + text = `${entry.plan.title}\n\n${entry.plan.markdown}`; + break; + case "plan_resolution": + text = `Plan ${entry.planId}: ${entry.resolution}`; + break; } return text?.trim() ? text : undefined; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f928934db..21fae9c78 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -161,6 +161,7 @@ import { formatAuthSelectorProviderType, OAuthSelectorComponent, } from "./components/oauth-selector.ts"; +import { PlanMessageComponent } from "./components/plan-message.ts"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts"; import { SessionSelectorComponent } from "./components/session-selector.ts"; import { SettingsSelectorComponent } from "./components/settings-selector.ts"; @@ -233,7 +234,7 @@ type CompactionQueuedMessage = { mode: "steer" | "followUp"; }; -type RenderSessionItem = AgentMessage | Extract; +type RenderSessionItem = AgentMessage | Extract; function isCustomSessionEntry(item: RenderSessionItem): item is Extract { return "type" in item && item.type === "custom"; @@ -1114,6 +1115,22 @@ export class InteractiveMode { // Show startup warnings const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options; + const preserveStartupMessages = (messages: string[], imageCount = 0, notice?: string): void => { + if (messages.length === 0 && imageCount === 0 && !notice) return; + const waitingText = messages.join("\n\n"); + const existingText = this.editor.getText(); + if (waitingText) { + this.editor.setText(existingText.trim() ? `${existingText}\n\n${waitingText}` : waitingText); + } + const promptNotice = messages.length + ? `${messages.length} startup ${messages.length === 1 ? "prompt is" : "prompts are"} waiting in the editor${notice ? "." : " for plan resolution."}` + : ""; + const imageNotice = imageCount + ? `${imageCount} attached startup ${imageCount === 1 ? "image" : "images"} must be reattached ${notice ? "before sending" : "after plan resolution"}.` + : ""; + this.showStatus([notice, promptNotice, imageNotice].filter(Boolean).join(" ")); + this.ui.requestRender(); + }; if (migratedProviders && migratedProviders.length > 0) { this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); @@ -1131,25 +1148,42 @@ export class InteractiveMode { void this.maybeWarnAboutAnthropicSubscriptionAuth(); // Process initial messages - if (initialMessage) { - try { - await this.promptUser(initialMessage, { images: initialImages }); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - this.showError(errorMessage); - } - } - - if (initialMessages) { - for (const message of initialMessages) { + const pendingPlanContinuation = this.session.pendingPlanContinuation; + if (this.session.pendingPlan || pendingPlanContinuation) { + preserveStartupMessages( + [...(initialMessage ? [initialMessage] : []), ...(initialMessages ?? [])], + initialImages?.length, + pendingPlanContinuation ? "Accepted plan implementation is waiting. Use /plan to retry." : undefined, + ); + } else { + if (initialMessage) { try { - await this.promptUser(message); + await this.promptUser(initialMessage, { images: initialImages }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); } } + + if (this.session.pendingPlan) { + preserveStartupMessages(initialMessages ?? []); + } else if (initialMessages) { + for (let index = 0; index < initialMessages.length; index++) { + const message = initialMessages[index]; + try { + await this.promptUser(message); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + this.showError(errorMessage); + } + if (this.session.pendingPlan) { + preserveStartupMessages(initialMessages.slice(index + 1)); + break; + } + } + } } + this.openPendingPlanDecision(); // Main interactive loop while (true) { @@ -1160,6 +1194,7 @@ export class InteractiveMode { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); } + this.openPendingPlanDecision(); } } @@ -1985,6 +2020,7 @@ export class InteractiveMode { this.chatContainer.clear(); this.renderInitialMessages(); + this.openPendingPlanDecision(); if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } @@ -2071,6 +2107,12 @@ export class InteractiveMode { await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); + if (options.renderBeforeBind) { + if (this.session.pendingPlanContinuation && !this.activeEditorUi?.preserveOnSessionRebind) { + this.showStatus("Accepted plan implementation is waiting. Use /plan to retry."); + } + this.openPendingPlanDecision(); + } } private async handleFatalRuntimeError(prefix: string, error: unknown): Promise { @@ -2671,6 +2713,18 @@ export class InteractiveMode { return "cancel"; } + private renderPlanArtifact(plan: PlanArtifact): void { + this.chatContainer.addChild( + new PlanMessageComponent( + plan, + this.getMarkdownThemeWithSettings(), + this.outputPad, + this.getMarkdownTransformers(), + ), + ); + this.ui.requestRender(); + } + private cancelPendingPlanDecision(): void { const controller = this.planDecisionAbortController; this.planDecisionAbortController = undefined; @@ -3436,9 +3490,13 @@ export class InteractiveMode { if (this.session.isStreaming) { this.editor.addToHistory?.(text); this.editor.setText(""); - await this.promptUser(text, { streamingBehavior: "steer" }); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); + try { + await this.promptUser(text, { streamingBehavior: "steer" }); + } finally { + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + this.openPendingPlanDecision(); + } return; } @@ -3514,6 +3572,19 @@ export class InteractiveMode { this.updateEditorBorderColor(); break; + case "collaboration_mode_changed": + this.ui.requestRender(); + break; + + case "plan_submitted": + this.renderPlanArtifact(event.plan); + break; + + case "plan_resolved": + if (this.suppressedPlanDecisionId === event.planId) this.suppressedPlanDecisionId = undefined; + this.ui.requestRender(); + break; + case "message_start": if (event.message.role === "custom") { this.addMessageToChat(event.message); @@ -4006,6 +4077,10 @@ export class InteractiveMode { this.addCustomEntryToChat(item); continue; } + if ("type" in item && item.type === "plan") { + this.renderPlanArtifact(item.plan); + continue; + } const message = item; // Assistant messages need special handling for tool calls @@ -4080,12 +4155,16 @@ export class InteractiveMode { options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { const items = entries.flatMap((entry): RenderSessionItem[] => { - if (entry.type === "custom") { + if (entry.type === "custom" || entry.type === "plan") { return [entry]; } return sessionEntryToContextMessages(entry); }); this.renderSessionItems(items, options); + const pendingPlan = this.session.pendingPlan; + if (pendingPlan && !entries.some((entry) => entry.type === "plan" && entry.plan.id === pendingPlan.id)) { + this.renderPlanArtifact(pendingPlan); + } } /** @@ -5535,6 +5614,7 @@ export class InteractiveMode { // Update UI this.chatContainer.clear(); this.renderInitialMessages(); + this.openPendingPlanDecision(); if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 2af6db3db..b98beba07 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -25,6 +25,7 @@ function createSession(options: { compactionUsage?: AssistantUsage; toolUsage?: AssistantUsage; usingSubscription?: boolean; + collaborationMode?: "default" | "plan"; }): AgentSession { const usage = options.usage; const entries: Array> = []; @@ -82,6 +83,7 @@ function createSession(options: { modelRuntime: { isUsingSubscription: () => options.usingSubscription ?? false, }, + collaborationMode: options.collaborationMode ?? "default", }; return session as unknown as AgentSession; @@ -172,4 +174,18 @@ describe("FooterComponent width handling", () => { expect(stripAnsi(footer.render(120).join("\n"))).not.toContain("/tmp/project"); }); + + it("keeps the plan-mode status visible at 60 columns", () => { + const session = createSession({ + sessionName: "", + modelId: "claude-opus-4-1", + thinkingLevel: "high", + collaborationMode: "plan", + }); + const footer = new FooterComponent(session, createFooterData(1)); + + const statsLine = stripAnsi(footer.render(60)[0]); + expect(statsLine.startsWith("[plan] ")).toBe(true); + expect(visibleWidth(statsLine)).toBeLessThanOrEqual(60); + }); }); diff --git a/packages/coding-agent/test/interactive-mode-startup-input.test.ts b/packages/coding-agent/test/interactive-mode-startup-input.test.ts index cac90442b..b6b214e17 100644 --- a/packages/coding-agent/test/interactive-mode-startup-input.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup-input.test.ts @@ -5,6 +5,9 @@ type SubmitContext = { defaultEditor: { onSubmit?: (text: string) => void }; editor: { addToHistory?: (text: string) => void; + getExpandedText?: () => string; + getText: () => string; + onSubmit?: (text: string) => void; setText: (text: string) => void; }; session: { @@ -14,6 +17,10 @@ type SubmitContext = { prompt: (text: string, options?: unknown) => Promise; }; flushPendingBashComponents: () => void; + promptUser: (text: string, options?: unknown) => Promise; + openPendingPlanDecision: (options?: { explicit?: boolean }) => void; + updatePendingMessagesDisplay: () => void; + ui: { requestRender: () => void }; onInputCallback?: (text: string) => void; pendingUserInputs: string[]; handleSubagentsCommand: () => void; @@ -27,6 +34,7 @@ type InputContext = { type InteractiveModePrivate = { setupEditorSubmitHandler(this: SubmitContext): void; getUserInput(this: InputContext): Promise; + handleFollowUp(this: SubmitContext): Promise; }; const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrivate; @@ -36,6 +44,7 @@ function createSubmitContext(): SubmitContext { defaultEditor: {}, editor: { addToHistory: vi.fn(), + getText: vi.fn(() => ""), setText: vi.fn(), }, session: { @@ -45,6 +54,10 @@ function createSubmitContext(): SubmitContext { prompt: vi.fn(async () => {}), }, flushPendingBashComponents: vi.fn(), + promptUser: vi.fn(async () => {}), + openPendingPlanDecision: vi.fn(), + updatePendingMessagesDisplay: vi.fn(), + ui: { requestRender: vi.fn() }, pendingUserInputs: [], handleSubagentsCommand: vi.fn(), }; @@ -90,4 +103,26 @@ describe("InteractiveMode startup input", () => { expect(context.editor.addToHistory).not.toHaveBeenCalled(); expect(context.pendingUserInputs).toEqual([]); }); + + it("routes streaming steer submissions through the runtime host", async () => { + const context = createSubmitContext(); + context.session.isStreaming = true; + interactiveModePrototype.setupEditorSubmitHandler.call(context); + + await context.defaultEditor.onSubmit?.("separate finance task"); + + expect(context.promptUser).toHaveBeenCalledWith("separate finance task", { streamingBehavior: "steer" }); + expect(context.session.prompt).not.toHaveBeenCalled(); + }); + + it("routes streaming follow-up submissions through the runtime host", async () => { + const context = createSubmitContext(); + context.session.isStreaming = true; + context.editor.getText = vi.fn(() => "separate legal task"); + + await interactiveModePrototype.handleFollowUp.call(context); + + expect(context.promptUser).toHaveBeenCalledWith("separate legal task", { streamingBehavior: "followUp" }); + expect(context.session.prompt).not.toHaveBeenCalled(); + }); }); diff --git a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts index 60da3fea5..e6b87e76e 100644 --- a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts +++ b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts @@ -77,6 +77,7 @@ type LoadedResourcesContext = { type RebindContext = { unsubscribe?: () => void; + session: { readonly pendingPlanContinuation: unknown }; applyRuntimeSettings: () => void; renderCurrentSessionState: () => void; bindCurrentSessionExtensions: () => Promise; @@ -84,6 +85,7 @@ type RebindContext = { updateAvailableProviderCount: () => Promise; updateEditorBorderColor: () => void; updateTerminalTitle: () => void; + openPendingPlanDecision: () => void; }; type ReloadCommandContext = { @@ -287,6 +289,7 @@ describe("regression #5943: session_start transient UI", () => { try { const context: RebindContext = { + session: harness.session, applyRuntimeSettings: () => events.push("apply"), renderCurrentSessionState: () => events.push("render"), bindCurrentSessionExtensions: async () => { @@ -300,6 +303,7 @@ describe("regression #5943: session_start transient UI", () => { updateAvailableProviderCount: async () => {}, updateEditorBorderColor: () => {}, updateTerminalTitle: () => {}, + openPendingPlanDecision: () => {}, }; await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true }); @@ -328,6 +332,7 @@ describe("regression #5943: session_start transient UI", () => { try { const context: RebindContext = { + session: harness.session, applyRuntimeSettings: () => {}, renderCurrentSessionState: () => events.push("render"), bindCurrentSessionExtensions: async () => { @@ -349,6 +354,7 @@ describe("regression #5943: session_start transient UI", () => { updateAvailableProviderCount: async () => {}, updateEditorBorderColor: () => {}, updateTerminalTitle: () => {}, + openPendingPlanDecision: () => {}, }; await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true }); @@ -380,6 +386,7 @@ describe("regression #5943: session_start transient UI", () => { try { const context: RebindContext = { + session: harness.session, applyRuntimeSettings: () => {}, renderCurrentSessionState: () => events.push("render"), bindCurrentSessionExtensions: async () => { @@ -401,6 +408,7 @@ describe("regression #5943: session_start transient UI", () => { updateAvailableProviderCount: async () => {}, updateEditorBorderColor: () => {}, updateTerminalTitle: () => {}, + openPendingPlanDecision: () => {}, }; await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true }); diff --git a/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts b/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts index 7b641d7f8..7ce8154fe 100644 --- a/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts +++ b/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts @@ -11,6 +11,7 @@ type RebindContext = { updateAvailableProviderCount: () => Promise; updateEditorBorderColor: () => void; updateTerminalTitle: () => void; + openPendingPlanDecision: () => void; }; type InteractiveModePrototype = { @@ -49,6 +50,7 @@ describe("overlapping startup and replacement session rebinds", () => { updateAvailableProviderCount: async () => {}, updateEditorBorderColor: () => {}, updateTerminalTitle, + openPendingPlanDecision: () => {}, }; const startupRebind = interactiveModePrototype.rebindCurrentSession.call(context); diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index ac348d191..47b14fea0 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -3,7 +3,10 @@ import { setKeybindings, visibleWidth } from "@earendil-works/diffex-tui"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.ts"; import type { + CollaborationModeChangeEntry, ModelChangeEntry, + PlanEntry, + PlanResolutionEntry, SessionEntry, SessionMessageEntry, SessionTreeNode, @@ -97,6 +100,41 @@ function modelChange(id: string, parentId: string | null): ModelChangeEntry { }; } +function collaborationModeChange(id: string, parentId: string | null): CollaborationModeChangeEntry { + return { + type: "collaboration_mode_change", + id, + parentId, + timestamp: new Date().toISOString(), + mode: "plan", + }; +} + +function planEntry(id: string, parentId: string | null): PlanEntry { + return { + type: "plan", + id, + parentId, + timestamp: new Date().toISOString(), + plan: { + id: "plan-1", + title: "Add native plan mode", + markdown: "Inspect the decision-ready implementation path.", + }, + }; +} + +function planResolution(id: string, parentId: string | null): PlanResolutionEntry { + return { + type: "plan_resolution", + id, + parentId, + timestamp: new Date().toISOString(), + planId: "plan-1", + resolution: "implement", + }; +} + // Helper to build a tree from entries using parentId relationships function buildTree(entries: Array): SessionTreeNode[] { if (entries.length === 0) return []; @@ -141,6 +179,63 @@ describe("TreeSelectorComponent", () => { expect(rendered).not.toContain("›"); }); + describe("plan entries", () => { + function buildPlanTree(): SessionTreeNode[] { + return buildTree([ + userMessage("user-1", null, "Plan this change"), + collaborationModeChange("mode-1", "user-1"), + planEntry("plan-entry-1", "mode-1"), + planResolution("resolution-1", "plan-entry-1"), + assistantMessage("asst-1", "resolution-1", "Implementation started"), + ]); + } + + test("hides plan bookkeeping while keeping the submitted plan in default and no-tools views", () => { + const selector = new TreeSelectorComponent( + buildPlanTree(), + "asst-1", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + for (const key of [undefined, "\x14"] as const) { + if (key) selector.handleInput(key); + const rendered = stripVTControlCharacters(list.render(200).join("\n")); + expect(rendered).toContain("[plan]: Add native plan mode"); + expect(rendered).not.toContain("[collaboration mode: plan]"); + expect(rendered).not.toContain("[plan resolution: implement]"); + } + }); + + test("renders all plan state, searches plan content, and copies the selected plan", () => { + const selector = new TreeSelectorComponent( + buildPlanTree(), + "asst-1", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + let copied: string | undefined; + selector.onCopy = (text) => { + copied = text; + }; + + selector.handleInput("\x01"); + const rendered = stripVTControlCharacters(list.render(200).join("\n")); + expect(rendered).toContain("[collaboration mode: plan]"); + expect(rendered).toContain("[plan]: Add native plan mode"); + expect(rendered).toContain("[plan resolution: implement]"); + + selector.handleInput("decision-ready"); + expect(list.getSelectedNode()?.entry.id).toBe("plan-entry-1"); + selector.handleInput("\x18"); + expect(copied).toBe("Add native plan mode\n\nInspect the decision-ready implementation path."); + }); + }); + describe("initial selection with metadata entries", () => { test("focuses nearest visible ancestor when currentLeafId is a model_change with sibling branch", () => { // Tree structure: