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: