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 669476f22..21fae9c78 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -57,8 +57,20 @@ 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 PlanContinuation, + 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 +92,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"; @@ -90,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"; @@ -147,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"; @@ -158,6 +173,7 @@ import { RetryStatusIndicator, type StatusIndicator, SubagentWaitStatusIndicator, + TaskBoundaryStatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; import { SubagentsComponent } from "./components/subagents.ts"; @@ -218,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"; @@ -434,8 +450,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 +465,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; @@ -470,6 +491,8 @@ export class InteractiveMode { // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); + private planDecisionAbortController: AbortController | undefined = undefined; + private suppressedPlanDecisionId: string | undefined = undefined; // Tool output expansion state private toolOutputExpanded = false; @@ -511,9 +534,6 @@ export class InteractiveMode { private shutdownRequested = false; // Extension UI state - private extensionSelector: ExtensionSelectorComponent | undefined = undefined; - private extensionInput: ExtensionInputComponent | undefined = undefined; - private extensionEditor: ExtensionEditorComponent | undefined = undefined; private extensionTerminalInputSubscriptions = new Set<{ handler: (data: string) => { consume?: boolean; data?: string } | undefined; unsubscribe: () => void; @@ -564,11 +584,16 @@ 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 }); }); + this.runtimeHost.setProfileSwitchListener((event) => { + this.showStatus(event.notice); + }); + this.runtimeHost.setTaskBoundaryResolver((proposal) => this.resolveTaskBoundary(proposal)); this.version = VERSION; this.renderer = createInteractiveTui({ tuiMode, @@ -707,6 +732,43 @@ 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 => { + 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 => { @@ -1053,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(", ")}`); @@ -1070,35 +1148,102 @@ export class InteractiveMode { void this.maybeWarnAboutAnthropicSubscriptionAuth(); // Process initial messages - if (initialMessage) { - try { - await this.session.prompt(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.session.prompt(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) { 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); } + this.openPendingPlanDecision(); + } + } + + 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(); + } } } @@ -1875,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); } @@ -1961,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 { @@ -1980,6 +2132,7 @@ export class InteractiveMode { this.streamingMessage = undefined; this.pendingTools.clear(); this.renderInitialMessages(); + this.updatePendingMessagesDisplay(); } /** @@ -2095,6 +2248,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 +2331,12 @@ 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 { + this.cancelPendingPlanDecision(); + 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 +2559,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 +2640,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 +2688,226 @@ 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 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; + controller?.abort(); + } + + private openPendingPlanDecision(options: { explicit?: boolean } = {}): void { + const plan = this.session.pendingPlan; + if (!plan || !this.session.isIdle || this.session.isStreaming || this.planDecisionAbortController) return; + if (this.activeEditorUi || this.renderer.hasOverlayEntries) return; + if (!options.explicit && this.suppressedPlanDecisionId === plan.id) return; + + this.suppressedPlanDecisionId = undefined; + void this.showPendingPlanDecision(plan).catch((error: unknown) => { + const retryHint = + this.session.pendingPlan?.id === plan.id ? " The plan remains pending; use /plan to retry." : ""; + this.showError( + `Failed to resolve plan: ${error instanceof Error ? error.message : String(error)}${retryHint}`, + ); + }); + } + + private async startPlanContinuation(continuation: PlanContinuation, onPromptOwned?: () => void): Promise { + const handoff = new BorderedLoader(this.ui, theme, "Starting implementation...", { + cancellable: false, + }); + let handoffReleased = false; + let handoffToken: object; + const releaseHandoff = () => { + if (handoffReleased) return; + handoffReleased = true; + handoff.dispose(); + this.closeEditorUi(handoffToken); + }; + handoffToken = this.mountEditorUi( + handoff, + handoff, + () => { + if (handoffReleased) return; + handoffReleased = true; + handoff.dispose(); + }, + { exclusive: true, preserveOnSessionRebind: true }, + ); + + let preflightAccepted = false; + let inputOwnershipReleased = false; + const releaseInputOwnership = () => { + if (inputOwnershipReleased) return; + inputOwnershipReleased = true; + releaseHandoff(); + onPromptOwned?.(); + }; + try { + const prompt = this.runtimeHost.promptPlanContinuation(continuation, { + source: "interactive", + preflightResult: (success) => { + preflightAccepted = success; + if (success) releaseInputOwnership(); + }, + }); + await prompt; + if (this.session.pendingPlanContinuation) { + this.showError("Plan acceptance persisted, but implementation did not start. Use /plan to retry."); + this.ui.requestRender(); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (preflightAccepted) { + this.showError(`Plan implementation failed after it started: ${message}`); + } else { + this.showError( + `Plan acceptance persisted, but implementation could not start: ${message} Use /plan to retry.`, + ); + } + this.ui.requestRender(); + } finally { + releaseHandoff(); + } + } + + private async showPendingPlanDecision(plan: PlanArtifact): Promise { + const session = this.session; + if (session.pendingPlan?.id !== plan.id || this.planDecisionAbortController) return; + + const decisionController = new AbortController(); + const releaseDecision = () => { + if (this.planDecisionAbortController !== decisionController) return; + this.planDecisionAbortController = undefined; + }; + this.planDecisionAbortController = decisionController; + const implement = "Implement"; + const keepPlanning = "Keep planning"; + const discard = "Discard"; + + try { + const selected = await this.showExtensionSelector( + "Plan ready\nReview the submitted plan above, then choose an action", + [implement, keepPlanning, discard], + { signal: decisionController.signal }, + ); + if (decisionController.signal.aborted || this.session !== session || session.pendingPlan?.id !== plan.id) { + return; + } + + if (selected === undefined || selected === keepPlanning) { + this.suppressedPlanDecisionId = plan.id; + this.showStatus("Plan remains pending. Use /plan to reopen it."); + return; + } + + if (selected === discard) { + session.resolvePlan(plan.id, "discard"); + this.showStatus("Plan discarded; plan mode disabled"); + return; + } + + const continuation = session.resolvePlan(plan.id, "implement"); + if (!continuation) { + this.showError("Plan acceptance persisted, but no implementation prompt was returned."); + return; + } + await this.startPlanContinuation(continuation, releaseDecision); + } finally { + releaseDecision(); + } + } + + private async handlePlanCommand(argument?: string): Promise { + if (argument !== undefined && argument !== "on" && argument !== "off") { + this.showError("Usage: /plan [on|off]"); + return; + } + + const continuation = this.session.pendingPlanContinuation; + if (continuation) { + if (argument === undefined) { + await this.startPlanContinuation(continuation); + return; + } + this.showError("Accepted plan implementation is waiting. Use /plan to retry it first."); + return; + } + + if (argument === undefined && this.session.collaborationMode === "plan" && this.session.pendingPlan) { + this.openPendingPlanDecision({ explicit: true }); + return; + } + + const targetMode = + argument === "on" + ? "plan" + : argument === "off" + ? "default" + : this.session.collaborationMode === "plan" + ? "default" + : "plan"; + + try { + if (targetMode === "default" && this.session.pendingPlan) { + const plan = this.session.pendingPlan; + const confirmed = await this.showExtensionConfirm( + "Discard pending plan", + "Disable plan mode and discard the pending plan?", + ); + if (!confirmed) return; + this.session.resolvePlan(plan.id, "discard"); + this.showStatus("Plan discarded; plan mode disabled"); + return; + } + + if (this.session.collaborationMode === targetMode) { + this.showStatus(`Plan mode is already ${targetMode === "plan" ? "enabled" : "disabled"}`); + return; + } + + this.session.setCollaborationMode(targetMode); + this.showStatus(`Plan mode ${targetMode === "plan" ? "enabled" : "disabled"}`); + } catch (error: unknown) { + this.showError(`Failed to change plan mode: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise { const confirmed = await this.showExtensionConfirm( "Session cwd not found", @@ -2496,89 +2930,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 +3010,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 +3073,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 +3107,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 +3122,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 +3152,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 +3343,18 @@ 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(""); + await this.handlePlanCommand(argument); + return; + } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -3049,9 +3490,13 @@ export class InteractiveMode { if (this.session.isStreaming) { this.editor.addToHistory?.(text); this.editor.setText(""); - await this.session.prompt(text, { streamingBehavior: "steer" }); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); + try { + await this.promptUser(text, { streamingBehavior: "steer" }); + } finally { + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + this.openPendingPlanDecision(); + } return; } @@ -3127,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); @@ -3619,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 @@ -3693,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); + } } /** @@ -4005,7 +4471,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 +4699,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 +4713,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 +4760,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 +4768,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 +4847,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 +4854,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 +5104,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 @@ -5087,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); } @@ -5129,6 +5657,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 +5692,7 @@ export class InteractiveMode { }, showRenameHint: true, keybindings: this.keybindings, + getHarnessName: (harnessId) => harnessNames.get(harnessId), }, this.sessionManager.getSessionFile(), @@ -5526,39 +6061,24 @@ export class InteractiveMode { } private showAmbientAuthDialog(providerOption: AuthSelectorProvider): void { - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; - + let token: object; const dialog = new LoginDialogComponent( this.ui, providerOption.id, - () => restoreEditor(), + () => this.closeEditorUi(token), providerOption.name, `${providerOption.name} setup`, ); dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside Diffex.`, [], true); - - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); + token = this.mountEditorUi(dialog, dialog, () => {}); } private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise { + const session = this.session; const previousModel = this.session.model; - - const dialog = new LoginDialogComponent( - this.ui, - providerId, - (_success, _message) => { - // Completion handled below - }, - providerName, - ); + const ownershipController = new AbortController(); + let token: object; + const dialog = new LoginDialogComponent(this.ui, providerId, () => this.closeEditorUi(token), providerName); if (providerId === "amazon-bedrock") { dialog.showDetails([ @@ -5568,24 +6088,19 @@ export class InteractiveMode { ]); } - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); - - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; + token = this.mountEditorUi(dialog, dialog, () => ownershipController.abort()); try { - await this.loginProvider(dialog, providerId, "api_key"); - restoreEditor(); + await this.loginProvider(dialog, providerId, "api_key", token, ownershipController.signal); + if (ownershipController.signal.aborted || this.session !== session) { + this.closeEditorUi(token); + return; + } await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); + this.closeEditorUi(token); } catch (error: unknown) { - restoreEditor(); + this.closeEditorUi(token); + if (ownershipController.signal.aborted) return; const errorMsg = error instanceof Error ? error.message : String(error); if (error instanceof CredentialSynchronizationError) { this.showError( @@ -5600,48 +6115,70 @@ export class InteractiveMode { private showAuthSelect( dialog: LoginDialogComponent, prompt: Extract, + editorToken: object, + ownershipSignal: AbortSignal, ): Promise { return new Promise((resolve, reject) => { - const restoreDialog = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); - }; + let settled = false; const labels = prompt.options.map((option) => option.label); const selector = new ExtensionSelectorComponent( prompt.message, labels, (optionLabel) => { - restoreDialog(); + if (settled) return; + settled = true; + ownershipSignal.removeEventListener("abort", onAbort); + selector.dispose(); + if (!this.replaceEditorUi(editorToken, dialog, dialog)) { + reject(new Error("Login cancelled")); + return; + } const id = prompt.options.find((option) => option.label === optionLabel)?.id; if (id) resolve(id); else reject(new Error("Login cancelled")); }, () => { - restoreDialog(); + if (settled) return; + settled = true; + ownershipSignal.removeEventListener("abort", onAbort); + selector.dispose(); + this.replaceEditorUi(editorToken, dialog, dialog); reject(new Error("Login cancelled")); }, ); - this.editorContainer.clear(); - this.editorContainer.addChild(selector); - this.ui.setFocus(selector); - this.ui.requestRender(); + const onAbort = () => { + if (settled) return; + settled = true; + selector.dispose(); + reject(new Error("Login cancelled")); + }; + if (ownershipSignal.aborted) { + onAbort(); + return; + } + ownershipSignal.addEventListener("abort", onAbort, { once: true }); + if (!this.replaceEditorUi(editorToken, selector, selector)) onAbort(); }); } - private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt): Promise { + private async showAuthPrompt( + dialog: LoginDialogComponent, + prompt: AuthPrompt, + editorToken: object, + ownershipSignal: AbortSignal, + ): Promise { let response: Promise; if (prompt.type === "select") { - response = this.showAuthSelect(dialog, prompt); + response = this.showAuthSelect(dialog, prompt, editorToken, ownershipSignal); } else if (prompt.type === "manual_code") { response = dialog.showManualInput(prompt.message); } else { response = dialog.showPrompt(prompt.message, prompt.placeholder); } - if (!prompt.signal) return response; - if (prompt.signal.aborted) throw new Error("Login cancelled"); - const signal = prompt.signal; + const signal = AbortSignal.any( + prompt.signal ? [dialog.signal, ownershipSignal, prompt.signal] : [dialog.signal, ownershipSignal], + ); + if (signal.aborted) throw new Error("Login cancelled"); let onAbort: (() => void) | undefined; const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(new Error("Login cancelled")); @@ -5671,35 +6208,35 @@ export class InteractiveMode { dialog: LoginDialogComponent, providerId: string, method: "api_key" | "oauth", + editorToken: object, + ownershipSignal: AbortSignal, ): Promise { await this.session.modelRuntime.login(providerId, method, { - signal: dialog.signal, - prompt: (prompt) => this.showAuthPrompt(dialog, prompt), + signal: AbortSignal.any([dialog.signal, ownershipSignal]), + prompt: (prompt) => this.showAuthPrompt(dialog, prompt, editorToken, ownershipSignal), notify: (event) => this.notifyAuthDialog(dialog, event), }); } private async showLoginDialog(providerId: string, providerName: string): Promise { + const session = this.session; const previousModel = this.session.model; - const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName); - this.editorContainer.clear(); - this.editorContainer.addChild(dialog); - this.ui.setFocus(dialog); - this.ui.requestRender(); - - const restoreEditor = () => { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; + const ownershipController = new AbortController(); + let token: object; + const dialog = new LoginDialogComponent(this.ui, providerId, () => this.closeEditorUi(token), providerName); + token = this.mountEditorUi(dialog, dialog, () => ownershipController.abort()); try { - await this.loginProvider(dialog, providerId, "oauth"); - restoreEditor(); + await this.loginProvider(dialog, providerId, "oauth", token, ownershipController.signal); + if (ownershipController.signal.aborted || this.session !== session) { + this.closeEditorUi(token); + return; + } await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); + this.closeEditorUi(token); } catch (error: unknown) { - restoreEditor(); + this.closeEditorUi(token); + if (ownershipController.signal.aborted) return; const errorMsg = error instanceof Error ? error.message : String(error); if (error instanceof CredentialSynchronizationError) { this.showError( @@ -5918,30 +6455,35 @@ export class InteractiveMode { // Show cancellable loader, replacing the editor const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - - const restoreEditor = () => { + let proc: ReturnType | null = null; + let cancelled = false; + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; loader.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); try { fs.unlinkSync(tmpFile); } catch { // Ignore cleanup errors } }; - - // Create a secret gist asynchronously - let proc: ReturnType | null = null; + const loaderToken = this.mountEditorUi(loader, loader, () => { + cancelled = true; + proc?.kill(); + cleanup(); + }); + if (cancelled) return; + const closeLoader = () => { + const closed = this.closeEditorUi(loaderToken); + cleanup(); + return closed; + }; loader.onAbort = () => { + cancelled = true; proc?.kill(); - restoreEditor(); - this.showStatus("Share cancelled"); + if (closeLoader()) this.showStatus("Share cancelled"); }; try { @@ -5958,9 +6500,9 @@ export class InteractiveMode { proc.on("close", (code) => resolve({ stdout, stderr, code })); }); - if (loader.signal.aborted) return; + if (cancelled || loader.signal.aborted) return; - restoreEditor(); + closeLoader(); if (result.code !== 0) { const errorMsg = result.stderr?.trim() || "Unknown error"; @@ -5984,8 +6526,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"}`); } } @@ -6050,7 +6592,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 +6966,10 @@ export class InteractiveMode { } stop(): void { - this.disposeActiveSelector(); + 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/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-share-command.test.ts b/packages/coding-agent/test/interactive-mode-share-command.test.ts index 83b118a26..e75eb1bfc 100644 --- a/packages/coding-agent/test/interactive-mode-share-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-share-command.test.ts @@ -23,6 +23,13 @@ type ShareCommandContext = { editorContainer: { clear: () => void; addChild: (child: unknown) => void }; editor: object; ui: { setFocus: (component: unknown) => void; requestRender: () => void }; + mountEditorUi: ( + component: unknown, + focusTarget: unknown, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, + ) => object; + closeEditorUi: (token: object) => boolean; showError: (message: string) => void; showStatus: (message: string) => void; }; @@ -63,6 +70,8 @@ function createContext(): { editorContainer: { clear: vi.fn(), addChild: vi.fn() }, editor: {}, ui: { setFocus: vi.fn(), requestRender: vi.fn() }, + mountEditorUi: vi.fn(() => ({})), + closeEditorUi: vi.fn(() => true), showError, showStatus, }, diff --git a/packages/coding-agent/test/interactive-mode-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/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); 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: