From ba539fcb2a46d0b8fa976a04c6d375478d7ff917 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:49:18 -0700 Subject: [PATCH] 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 b17537463..4ea74419f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -154,6 +154,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"; @@ -225,7 +226,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"; @@ -1080,6 +1081,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(", ")}`); @@ -1097,25 +1114,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) { @@ -1126,6 +1160,7 @@ export class InteractiveMode { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); } + this.openPendingPlanDecision(); } } @@ -1906,6 +1941,7 @@ export class InteractiveMode { this.chatContainer.clear(); this.renderInitialMessages(); + this.openPendingPlanDecision(); if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } @@ -1992,6 +2028,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 { @@ -2562,6 +2604,18 @@ export class InteractiveMode { return result === "Yes"; } + 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; @@ -3321,9 +3375,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; } @@ -3399,6 +3457,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); @@ -3891,6 +3962,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 @@ -3965,12 +4040,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); + } } /** @@ -5345,6 +5424,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: