From df548fea3619cd2533e8dfd8bb88941dcc4999ca Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:51:24 -0700 Subject: [PATCH 1/3] fix(coding-agent): preserve plan task context Keep profile, harness, and accepted-plan state visible across footer variants while sanitizing task-facing labels. --- .../modes/interactive/components/footer.ts | 71 ++++++++-- .../src/modes/interactive/interactive-mode.ts | 60 ++++++--- .../coding-agent/test/footer-width.test.ts | 121 ++++++++++++++++-- 3 files changed, 217 insertions(+), 35 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 16cb8347b..4e2177c60 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,5 +1,5 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; -import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/diffex-tui"; +import { type Component, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/diffex-tui"; import type { AgentSession } from "../../../core/agent-session.ts"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; import { theme } from "../theme/theme.ts"; @@ -9,10 +9,9 @@ import { theme } from "../theme/theme.ts"; * Removes newlines, tabs, carriage returns, and other control characters. */ function sanitizeStatusText(text: string): string { - // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces - return text - .replace(/[\r\n\t]/g, " ") - .replace(/ +/g, " ") + return stripTerminalSequences(text) + .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ") + .replace(/\s+/gu, " ") .trim(); } @@ -41,6 +40,58 @@ export function formatCwdForFooter(cwd: string, home: string | undefined): strin return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`; } +export interface FooterTaskContext { + profileName: string; + profileRouting: "auto" | "pinned"; + harnessName?: string; +} + +/** Persistent task context shown above either the built-in or an extension footer. */ +export class FooterTaskContextComponent implements Component { + private session: AgentSession; + private readonly getTaskContext: () => FooterTaskContext; + + constructor(session: AgentSession, getTaskContext: () => FooterTaskContext) { + this.session = session; + this.getTaskContext = getTaskContext; + } + + setSession(session: AgentSession): void { + this.session = session; + } + + invalidate(): void {} + + render(width: number): string[] { + const taskContext = this.getTaskContext(); + const planStatus = this.session.collaborationMode === "plan" ? "[plan] " : ""; + const profileName = sanitizeStatusText(taskContext.profileName) || "unknown"; + const harnessName = sanitizeStatusText(taskContext.harnessName ?? "not selected") || "not selected"; + const profilePrefix = `${planStatus}Profile: `; + const middle = ` (${taskContext.profileRouting}) • Harness: `; + const fixedWidth = visibleWidth(profilePrefix) + visibleWidth(middle); + const availableValueWidth = width - fixedWidth; + let contextLine: string; + + if (availableValueWidth >= 2) { + const profileDesiredWidth = visibleWidth(profileName); + const harnessDesiredWidth = visibleWidth(harnessName); + let profileWidth = Math.min(profileDesiredWidth, Math.max(1, Math.floor(availableValueWidth / 2))); + let harnessWidth = Math.min(harnessDesiredWidth, Math.max(1, availableValueWidth - profileWidth)); + let remainingWidth = availableValueWidth - profileWidth - harnessWidth; + const additionalProfileWidth = Math.min(remainingWidth, profileDesiredWidth - profileWidth); + profileWidth += additionalProfileWidth; + remainingWidth -= additionalProfileWidth; + harnessWidth += Math.min(remainingWidth, harnessDesiredWidth - harnessWidth); + contextLine = `${profilePrefix}${truncateToWidth(profileName, profileWidth, "...")}${middle}${truncateToWidth(harnessName, harnessWidth, "...")}`; + } else { + contextLine = truncateToWidth(`${profilePrefix}${profileName}${middle}${harnessName}`, width, "..."); + } + + return [theme.fg("dim", contextLine)]; + } +} + /** * Footer component that shows model, thinking, and context usage. * Gets context usage from the session and extension statuses from provider. @@ -48,14 +99,17 @@ export function formatCwdForFooter(cwd: string, home: string | undefined): strin export class FooterComponent implements Component { private session: AgentSession; private footerData: ReadonlyFooterDataProvider; + private readonly taskContext: FooterTaskContextComponent; - constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider) { + constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider, getTaskContext: () => FooterTaskContext) { this.session = session; this.footerData = footerData; + this.taskContext = new FooterTaskContextComponent(session, getTaskContext); } setSession(session: AgentSession): void { this.session = session; + this.taskContext.setSession(session); } /** @@ -98,14 +152,13 @@ export class FooterComponent implements Component { const modelName = state.model?.id || "no-model"; const thinkingLevel = state.thinkingLevel || "off"; const windowDisplay = `${formatTokens(contextWindow)} window`; - const planStatus = this.session.collaborationMode === "plan" ? "[plan] " : ""; - let statsLine = `${planStatus}${modelName} ${thinkingLevel} • ${contextPercentStr} • ${windowDisplay}`; + let statsLine = `${modelName} ${thinkingLevel} • ${contextPercentStr} • ${windowDisplay}`; if (visibleWidth(statsLine) > width) { statsLine = truncateToWidth(statsLine, width, "..."); } - const lines = [theme.fg("dim", statsLine)]; + const lines = [...this.taskContext.render(width), theme.fg("dim", statsLine)]; // Add extension statuses on a single line, sorted by key alphabetically const extensionStatuses = this.footerData.getExtensionStatuses(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 21fae9c78..abe634fb1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -37,6 +37,7 @@ import { SELECTED_LIST_PREFIX, Spacer, setKeybindings, + stripTerminalSequences, Text, TruncatedText, type TUI, @@ -151,8 +152,8 @@ import { EarendilAnnouncementComponent } from "./components/earendil-announcemen import { ExtensionEditorComponent } from "./components/extension-editor.ts"; import { ExtensionInputComponent } from "./components/extension-input.ts"; import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; -import { FooterComponent, formatCwdForFooter, formatTokens } from "./components/footer.ts"; -import { formatKeyText, keyDisplayText } from "./components/keybinding-hints.ts"; +import { FooterComponent, FooterTaskContextComponent, formatCwdForFooter, formatTokens } from "./components/footer.ts"; +import { formatKeyText, keyDisplayText, keyText } from "./components/keybinding-hints.ts"; import { LoginDialogComponent } from "./components/login-dialog.ts"; import { createMermaidMarkdownTransformer } from "./components/mermaid.ts"; import { ModelSelectorComponent } from "./components/model-selector.ts"; @@ -250,6 +251,13 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } +function sanitizeSingleLineUiText(value: string): string { + return stripTerminalSequences(value) + .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ") + .replace(/\s+/gu, " ") + .trim(); +} + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; @@ -457,6 +465,7 @@ export class InteractiveMode { preserveOnSessionRebind: boolean; }; private footer: FooterComponent; + private footerTaskContext: FooterTaskContextComponent; private footerContainer: Container; private footerDataProvider: FooterDataProvider; // Stored so the same manager can be injected into custom editors, selectors, and extension UI. @@ -591,7 +600,7 @@ export class InteractiveMode { await this.rebindCurrentSession({ renderBeforeBind: true }); }); this.runtimeHost.setProfileSwitchListener((event) => { - this.showStatus(event.notice); + this.showStatus(sanitizeSingleLineUiText(event.notice)); }); this.runtimeHost.setTaskBoundaryResolver((proposal) => this.resolveTaskBoundary(proposal)); this.version = VERSION; @@ -632,7 +641,13 @@ export class InteractiveMode { this.editorContainer = new Container(); this.editorContainer.addChild(this.editor as Component); this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd()); - this.footer = new FooterComponent(this.session, this.footerDataProvider); + const getFooterTaskContext = () => ({ + profileName: this.runtimeHost.activeProfile.name, + profileRouting: this.runtimeHost.profileRouting, + ...(this.runtimeHost.activeHarness ? { harnessName: this.runtimeHost.activeHarness.name } : {}), + }); + this.footerTaskContext = new FooterTaskContextComponent(this.session, getFooterTaskContext); + this.footer = new FooterComponent(this.session, this.footerDataProvider, getFooterTaskContext); this.footerContainer = new Container(); this.footerContainer.addChild(this.footer); @@ -740,8 +755,8 @@ export class InteractiveMode { { 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, + name: sanitizeSingleLineUiText(profile.name), + description: sanitizeSingleLineUiText(profile.description), })), ]; return createFuzzyAutocompleteItems( @@ -2024,7 +2039,7 @@ export class InteractiveMode { if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } - this.showStatus("Navigated to selected point"); + this.showTreeNavigationStatus(); void this.flushCompactionQueue({ willRetry: false }); return { cancelled: false }; }, @@ -2055,6 +2070,14 @@ export class InteractiveMode { this.showStartupNoticesIfNeeded(); } + private showTreeNavigationStatus(): void { + this.showStatus( + this.session.pendingPlanContinuation + ? "Navigated to selected point. Accepted plan implementation is waiting; use /plan to retry." + : "Navigated to selected point", + ); + } + private applyFullscreenScrollbarSetting(): void { this.transcriptScrollView?.setScrollbar(this.settingsManager.getFullscreenScrollbar()); } @@ -2063,6 +2086,7 @@ export class InteractiveMode { configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.applyFullscreenScrollbarSetting(); this.footer.setSession(this.session); + this.footerTaskContext.setSession(this.session); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); this.outputPad = this.settingsManager.getOutputPad(); @@ -2410,6 +2434,7 @@ export class InteractiveMode { if (factory) { // Create and add custom footer, passing the data provider this.customFooter = factory(this.ui, theme, this.footerDataProvider); + this.footerContainer.addChild(this.footerTaskContext); this.footerContainer.addChild(this.customFooter); } else { // Restore built-in footer @@ -2692,10 +2717,13 @@ export class InteractiveMode { this.clearStatusIndicator("taskBoundary"); const activeHarness = this.runtimeHost.activeHarness; const currentHarnessName = - activeHarness?.id === proposal.fromHarnessId ? activeHarness.name : proposal.fromHarnessId; + activeHarness?.id === proposal.fromHarnessId + ? sanitizeSingleLineUiText(activeHarness.name) + : sanitizeSingleLineUiText(proposal.fromHarnessId); + const targetHarnessName = sanitizeSingleLineUiText(proposal.targetHarnessName); 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 targetLabel = `${targetAction}: ${targetHarnessName} (${sanitizeSingleLineUiText(proposal.toProfileId)})`; + const currentLabel = `Keep current: ${currentHarnessName} (${sanitizeSingleLineUiText(proposal.fromProfileId)})`; const selected = await this.showExtensionSelector("New task detected\nChoose a harness for this prompt", [ targetLabel, currentLabel, @@ -2703,7 +2731,7 @@ export class InteractiveMode { if (selected === targetLabel) { const action = targetAction === "Resume" ? "Resuming" : targetAction === "Reuse" ? "Reusing" : "Creating"; - this.showTaskRoutingProgress(`${action} ${proposal.targetHarnessName}...`); + this.showTaskRoutingProgress(`${action} ${targetHarnessName}...`); return "switch"; } if (selected === currentLabel) { @@ -5122,7 +5150,7 @@ export class InteractiveMode { 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); + labels.set(`${profile.id} - ${sanitizeSingleLineUiText(profile.name)}${active}`, profile.id); } const selectedLabel = await this.showExtensionSelector( `Task profile: ${activeProfile.id} (${this.runtimeHost.profileRouting})`, @@ -5618,7 +5646,7 @@ export class InteractiveMode { if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } - this.showStatus("Navigated to selected point"); + this.showTreeNavigationStatus(); void this.flushCompactionQueue({ willRetry: false }); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); @@ -5660,7 +5688,7 @@ export class InteractiveMode { const harnessNames = new Map( loadHarnessDescriptors({ agentDir: this.runtimeHost.services.agentDir }).harnesses.map((harness) => [ harness.id, - harness.name, + sanitizeSingleLineUiText(harness.name), ]), ); this.showSelector((done) => { @@ -6594,7 +6622,9 @@ export class InteractiveMode { info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n`; const activeProfile = this.runtimeHost.activeProfile; - info += `${theme.fg("dim", "Profile:")} ${activeProfile.name} (${activeProfile.id}, ${this.runtimeHost.profileRouting})\n\n`; + info += `${theme.fg("dim", "Profile:")} ${sanitizeSingleLineUiText(activeProfile.name)} (${sanitizeSingleLineUiText(activeProfile.id)}, ${this.runtimeHost.profileRouting})\n`; + const activeHarness = this.runtimeHost.activeHarness; + info += `${theme.fg("dim", "Harness:")} ${activeHarness ? `${sanitizeSingleLineUiText(activeHarness.name)} (${sanitizeSingleLineUiText(activeHarness.id)})` : "not selected"}\n\n`; info += `${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n`; info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index b98beba07..846287e21 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -1,8 +1,13 @@ -import { visibleWidth } from "@earendil-works/diffex-tui"; +import { Container, Text, visibleWidth } from "@earendil-works/diffex-tui"; import { beforeAll, describe, expect, it } from "vitest"; import type { AgentSession } from "../src/core/agent-session.ts"; import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts"; -import { FooterComponent, formatCwdForFooter } from "../src/modes/interactive/components/footer.ts"; +import { + FooterComponent, + FooterTaskContextComponent, + formatCwdForFooter, +} from "../src/modes/interactive/components/footer.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; import { stripAnsi } from "../src/utils/ansi.ts"; @@ -103,6 +108,25 @@ function createFooterData(providerCount: number): ReadonlyFooterDataProvider { return provider; } +const defaultTaskContext = () => ({ + profileName: "General", + profileRouting: "auto" as const, +}); + +interface ExtensionFooterContext { + customFooter?: { dispose?: () => void }; + footerContainer: Container; + footerTaskContext: FooterTaskContextComponent; + footer: Text; + footerDataProvider: ReadonlyFooterDataProvider; + ui: { requestRender: () => void }; +} + +const setExtensionFooter = Reflect.get(InteractiveMode.prototype, "setExtensionFooter") as ( + this: ExtensionFooterContext, + factory: (() => Text) | undefined, +) => void; + describe("formatCwdForFooter", () => { it("does not abbreviate sibling paths that share the home prefix", () => { expect(formatCwdForFooter("/home/user2", "/home/user")).toBe("/home/user2"); @@ -122,7 +146,7 @@ describe("FooterComponent width handling", () => { it("keeps all lines within width for wide session names", () => { const width = 93; const session = createSession({ sessionName: "한글".repeat(30) }); - const footer = new FooterComponent(session, createFooterData(1)); + const footer = new FooterComponent(session, createFooterData(1), defaultTaskContext); const lines = footer.render(width); for (const line of lines) { @@ -146,7 +170,7 @@ describe("FooterComponent width handling", () => { cost: { total: 1.234 }, }, }); - const footer = new FooterComponent(session, createFooterData(2)); + const footer = new FooterComponent(session, createFooterData(2), defaultTaskContext); const lines = footer.render(width); for (const line of lines) { @@ -160,17 +184,39 @@ describe("FooterComponent width handling", () => { modelId: "gpt-5.5", thinkingLevel: "medium", }); - const footer = new FooterComponent(session, createFooterData(1)); + const footer = new FooterComponent(session, createFooterData(1), defaultTaskContext); - const statsLine = stripAnsi(footer.render(120)[0]); + const statsLine = stripAnsi(footer.render(120)[1]); expect(statsLine).toBe("gpt-5.5 medium • Context 12.3% used • 200k window"); }); + it("reads the active profile and harness on every render", () => { + const session = createSession({ sessionName: "" }); + const taskContext: { + profileName: string; + profileRouting: "auto" | "pinned"; + harnessName?: string; + } = { + profileName: "General", + profileRouting: "auto", + }; + const footer = new FooterComponent(session, createFooterData(1), () => taskContext); + + expect(stripAnsi(footer.render(80)[0] ?? "")).toBe("Profile: General (auto) • Harness: not selected"); + + taskContext.profileName = "Coding"; + taskContext.profileRouting = "pinned"; + taskContext.harnessName = "Financial Decision Visualizer"; + expect(stripAnsi(footer.render(80)[0] ?? "")).toBe( + "Profile: Coding (pinned) • Harness: Financial Decision Visualizer", + ); + }); + it("does not show the project directory in the footer", () => { const session = createSession({ sessionName: "work", }); - const footer = new FooterComponent(session, createFooterData(1)); + const footer = new FooterComponent(session, createFooterData(1), defaultTaskContext); expect(stripAnsi(footer.render(120).join("\n"))).not.toContain("/tmp/project"); }); @@ -182,10 +228,63 @@ describe("FooterComponent width handling", () => { thinkingLevel: "high", collaborationMode: "plan", }); - const footer = new FooterComponent(session, createFooterData(1)); + const footer = new FooterComponent(session, createFooterData(1), () => ({ + profileName: "Finance", + profileRouting: "auto", + harnessName: "Financial Decision Visualizer With A Deliberately Long Name", + })); + + const lines = footer.render(60).map(stripAnsi); + expect(lines[0]).toMatch(/^\[plan\] Profile: Finance \(auto\) • Harness: Financial/); + expect(lines[1]).toBe("claude-opus-4-1 high • Context 12.3% used • 200k window"); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(60); + }); + + it("keeps both long profile and harness names visible at 60 columns", () => { + const session = createSession({ sessionName: "", collaborationMode: "plan" }); + const taskContext = new FooterTaskContextComponent(session, () => ({ + profileName: "Specialized Finance Profile With A Deliberately Long Name", + profileRouting: "auto", + harnessName: "Financial Decision Visualizer With A Deliberately Long Name", + })); + + const line = stripAnsi(taskContext.render(60)[0] ?? ""); + expect(line).toMatch(/^\[plan\] Profile: Spec.*\.\.\. \(auto\) • Harness: Fin.*\.\.\.$/); + expect(visibleWidth(line)).toBeLessThanOrEqual(60); + }); + + it("strips terminal sequences from profile and harness names", () => { + const session = createSession({ sessionName: "" }); + const taskContext = new FooterTaskContextComponent(session, () => ({ + profileName: "\u001b]8;;https://example.invalid\u0007Finance\u001b]8;;\u0007\u001b[2J", + profileRouting: "pinned", + harnessName: "Decision\u001b[31m Visualizer\u001b[0m\u0000", + })); + + const line = stripAnsi(taskContext.render(80)[0] ?? ""); + expect(line).toBe("Profile: Finance (pinned) • Harness: Decision Visualizer"); + expect(line).not.toContain("example.invalid"); + }); + + it("keeps task context mounted above a custom extension footer", () => { + const session = createSession({ sessionName: "", collaborationMode: "plan" }); + const footerContainer = new Container(); + const footerTaskContext = new FooterTaskContextComponent(session, () => ({ + profileName: "Finance", + profileRouting: "auto", + harnessName: "Decision Visualizer", + })); + const context: ExtensionFooterContext = { + footerContainer, + footerTaskContext, + footer: new Text("built-in footer"), + footerDataProvider: createFooterData(1), + ui: { requestRender: () => {} }, + }; + + setExtensionFooter.call(context, () => new Text("custom footer", 0, 0)); - const statsLine = stripAnsi(footer.render(60)[0]); - expect(statsLine.startsWith("[plan] ")).toBe(true); - expect(visibleWidth(statsLine)).toBeLessThanOrEqual(60); + const rendered = footerContainer.render(60).map((line) => stripAnsi(line).trimEnd()); + expect(rendered).toEqual(["[plan] Profile: Finance (auto) • Harness: Decision Visual...", "custom footer"]); }); }); From 928aa9b0915f90311bc2c1d3addb64cb30e96be5 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:51:47 -0700 Subject: [PATCH 2/3] feat(coding-agent): resolve ambiguous plan profiles Present bounded profile safeguards and tool policies during plan handoff, and preserve retryable ownership on cancellation. --- .../src/modes/interactive/interactive-mode.ts | 137 ++++++++++++++++-- 1 file changed, 126 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index abe634fb1..4a1a5f0b3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -43,7 +43,9 @@ import { type TUI, TuiAltScreen, TuiMainScreen, + truncateToWidth, visibleWidth, + wrapTextWithAnsi, } from "@earendil-works/diffex-tui"; import chalk from "chalk"; import { spawn, spawnSync } from "child_process"; @@ -67,6 +69,8 @@ import { } from "../../core/agent-session.ts"; import { type AgentSessionRuntime, + type ProfileSelectionProposal, + type ProfileSelectionResolution, SessionImportFileNotFoundError, TaskBoundaryCancelledError, type TaskBoundaryProposal, @@ -2626,8 +2630,17 @@ export class InteractiveMode { return true; } - private replaceEditorUi(token: object, component: Component, focus: Component): boolean { + private replaceEditorUi( + token: object, + component: Component, + focus: Component, + options: { cancel?: () => void; preserveOnSessionRebind?: boolean } = {}, + ): boolean { if (this.activeEditorUi?.token !== token) return false; + if (options.cancel) this.activeEditorUi.cancel = options.cancel; + if (options.preserveOnSessionRebind !== undefined) { + this.activeEditorUi.preserveOnSessionRebind = options.preserveOnSessionRebind; + } this.editorContainer.clear(); this.editorContainer.addChild(component); this.ui.setFocus(focus); @@ -2759,6 +2772,93 @@ export class InteractiveMode { controller?.abort(); } + private resolvePlanProfileSelection( + proposal: ProfileSelectionProposal, + signal: AbortSignal, + handoffToken: object, + handoff: BorderedLoader, + handoffCancel: () => void, + ): Promise { + return new Promise((resolve) => { + if (signal.aborted || this.activeEditorUi?.token !== handoffToken) { + resolve({ cancelled: true }); + return; + } + + const clean = (value: string): string => sanitizeSingleLineUiText(value); + const labels = proposal.options.map((option, index) => { + const summarizeTools = (tools: readonly string[]): string => { + const visibleTools = tools.slice(0, 2).map(clean); + return `${visibleTools.join(", ")}${tools.length > visibleTools.length ? ` +${tools.length - visibleTools.length}` : ""}`; + }; + const policy: string[] = []; + if (option.toolPolicy.required.length > 0) { + policy.push(`requires ${summarizeTools(option.toolPolicy.required)}`); + } + if (option.toolPolicy.denied.length > 0) { + policy.push(`no ${summarizeTools(option.toolPolicy.denied)}`); + } + if (policy.length === 0) { + policy.push( + option.toolPolicy.default.includes("bash") + ? "bash enabled" + : `tools: ${summarizeTools(option.toolPolicy.default) || "profile defaults"}`, + ); + } + const recommended = index === 0 ? " (recommended)" : ""; + const description = clean(option.description).split(/[,.]/, 1)[0]; + const optionWidth = Math.max(20, this.ui.terminal.columns - 6); + const profileLine = truncateToWidth( + `${index + 1}. ${clean(option.name)}${recommended} - ${description}`, + optionWidth, + "...", + ); + const safeguardLines = wrapTextWithAnsi( + `Safeguards: ${clean(option.safeguardSummary)}`, + Math.max(1, optionWidth - 3), + ).map((line) => ` ${line}`); + const toolLine = truncateToWidth(` Tools: ${policy.join("; ")}`, optionWidth, "..."); + return [profileLine, ...safeguardLines, toolLine].join("\n"); + }); + const profileIds = new Map(labels.map((label, index) => [label, proposal.options[index]?.profileId])); + + let settled = false; + let selector: ExtensionSelectorComponent | undefined; + const restoreHandoff = (): boolean => + this.replaceEditorUi(handoffToken, handoff, handoff, { + cancel: handoffCancel, + preserveOnSessionRebind: true, + }); + const settle = (resolution: ProfileSelectionResolution, restore: boolean): void => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + selector?.dispose(); + resolve(restore && !restoreHandoff() ? { cancelled: true } : resolution); + }; + const cancelOwner = () => settle({ cancelled: true }, false); + const onAbort = () => this.cancelEditorUi(handoffToken); + selector = new ExtensionSelectorComponent( + "Choose an implementation profile\nThis changes expertise, safeguards, and tool access", + labels, + (label) => { + const profileId = profileIds.get(label); + settle(profileId ? { profileId } : { cancelled: true }, true); + }, + () => settle({ cancelled: true }, true), + ); + signal.addEventListener("abort", onAbort, { once: true }); + if ( + !this.replaceEditorUi(handoffToken, selector, selector, { + cancel: cancelOwner, + preserveOnSessionRebind: false, + }) + ) { + settle({ cancelled: true }, false); + } + }); + } + private openPendingPlanDecision(options: { explicit?: boolean } = {}): void { const plan = this.session.pendingPlan; if (!plan || !this.session.isIdle || this.session.isStreaming || this.planDecisionAbortController) return; @@ -2787,18 +2887,18 @@ export class InteractiveMode { handoff.dispose(); this.closeEditorUi(handoffToken); }; - handoffToken = this.mountEditorUi( - handoff, - handoff, - () => { - if (handoffReleased) return; - handoffReleased = true; - handoff.dispose(); - }, - { exclusive: true, preserveOnSessionRebind: true }, - ); + const handoffCancel = () => { + if (handoffReleased) return; + handoffReleased = true; + handoff.dispose(); + }; + handoffToken = this.mountEditorUi(handoff, handoff, handoffCancel, { + exclusive: true, + preserveOnSessionRebind: true, + }); let preflightAccepted = false; + let profileSelectionCancelled = false; let inputOwnershipReleased = false; const releaseInputOwnership = () => { if (inputOwnershipReleased) return; @@ -2809,6 +2909,17 @@ export class InteractiveMode { try { const prompt = this.runtimeHost.promptPlanContinuation(continuation, { source: "interactive", + profileSelectionResolver: async (proposal, signal) => { + const resolution = await this.resolvePlanProfileSelection( + proposal, + signal, + handoffToken, + handoff, + handoffCancel, + ); + profileSelectionCancelled = "cancelled" in resolution; + return resolution; + }, preflightResult: (success) => { preflightAccepted = success; if (success) releaseInputOwnership(); @@ -2823,6 +2934,10 @@ export class InteractiveMode { const message = error instanceof Error ? error.message : String(error); if (preflightAccepted) { this.showError(`Plan implementation failed after it started: ${message}`); + } else if (profileSelectionCancelled) { + this.showStatus( + "Profile selection cancelled. Accepted plan implementation is waiting; use /plan to retry.", + ); } else { this.showError( `Plan acceptance persisted, but implementation could not start: ${message} Use /plan to retry.`, From 56ea02bc02114cf14eba807d02c803cd6b2a3c44 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:52:09 -0700 Subject: [PATCH 3/3] test(coding-agent): add plan interaction harness Centralize native plan fixtures, reflective method bindings, virtual terminal setup, and editor ownership helpers. --- .../src/modes/interactive/interactive-mode.ts | 2 +- .../test/interactive-mode-plan-test-utils.ts | 456 ++++++++++++++++++ 2 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/interactive-mode-plan-test-utils.ts diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4a1a5f0b3..f79abd23d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -157,7 +157,7 @@ import { ExtensionEditorComponent } from "./components/extension-editor.ts"; import { ExtensionInputComponent } from "./components/extension-input.ts"; import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; import { FooterComponent, FooterTaskContextComponent, formatCwdForFooter, formatTokens } from "./components/footer.ts"; -import { formatKeyText, keyDisplayText, keyText } from "./components/keybinding-hints.ts"; +import { formatKeyText, keyDisplayText } from "./components/keybinding-hints.ts"; import { LoginDialogComponent } from "./components/login-dialog.ts"; import { createMermaidMarkdownTransformer } from "./components/mermaid.ts"; import { ModelSelectorComponent } from "./components/model-selector.ts"; diff --git a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts new file mode 100644 index 000000000..27b490057 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts @@ -0,0 +1,456 @@ +import { type Component, Container, type Focusable, type TUI, TuiMainScreen } from "@earendil-works/diffex-tui"; +import { vi } from "vitest"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; +import type { PlanContinuation } from "../src/core/agent-session.ts"; +import type { ProfileSelectionProposal, ProfileSelectionResolution } from "../src/core/agent-session-runtime.ts"; +import type { PlanArtifact } from "../src/core/plan-mode.ts"; +import type { PlanEntry, SessionEntry } from "../src/core/session-manager.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +export const PLAN: PlanArtifact = { + id: "plan-1", + title: "Add native plan mode", + markdown: "1. Inspect the current session.\n2. Implement the approved changes.\n3. Run focused verification.", +}; + +export const PROFILE_PROPOSAL: ProfileSelectionProposal = { + trigger: "accepted_plan", + options: [ + { + profileId: "finance", + name: "Finance", + description: "Financial research, analysis, calculations, and decision support.", + safeguardSummary: "High stakes; current sources; human review; bash unavailable.", + toolPolicy: { default: ["read", "write", "edit"], required: [], denied: ["bash"] }, + }, + { + profileId: "coding", + name: "Coding", + description: "Software engineering, debugging, code review, and repository maintenance.", + safeguardSummary: "Standard safeguards; bash/edit/write available.", + toolPolicy: { default: ["read", "bash", "edit", "write"], required: [], denied: [] }, + }, + ], +}; + +export interface PlanDecisionSession { + pendingPlan: PlanArtifact | undefined; + pendingPlanContinuation?: PlanContinuation; + isStreaming?: boolean; + resolvePlan: ReturnType; + promptPlanContinuation: ReturnType; +} + +export interface PlanDecisionContext { + session: PlanDecisionSession; + runtimeHost: { promptPlanContinuation: ReturnType }; + planDecisionAbortController?: AbortController; + suppressedPlanDecisionId?: string; + showExtensionSelector: ReturnType; + showStatus: ReturnType; + showError: ReturnType; + mountEditorUi: ReturnType; + closeEditorUi: ReturnType; + resolvePlanProfileSelection: ( + proposal: ProfileSelectionProposal, + signal: AbortSignal, + handoffToken: object, + handoff: Component, + handoffCancel: () => void, + ) => Promise; + startPlanContinuation: (continuation: PlanContinuation, onPromptOwned?: () => void) => Promise; + editor: { + getText: () => string; + setText: ReturnType; + }; + ui: { requestRender: ReturnType }; +} + +export const showPendingPlanDecision = Reflect.get(InteractiveMode.prototype, "showPendingPlanDecision") as ( + this: PlanDecisionContext, + plan: PlanArtifact, +) => Promise; + +export interface OpenPlanDecisionContext { + session: { pendingPlan: PlanArtifact | undefined; isIdle: boolean; isStreaming: boolean }; + planDecisionAbortController?: AbortController; + suppressedPlanDecisionId?: string; + activeEditorUi?: { token: object; cancel: () => void; exclusive?: boolean }; + showPendingPlanDecision: ReturnType; + showError: ReturnType; + renderer: { hasOverlayEntries: boolean }; +} + +export interface PlanSubmittedEventContext { + isInitialized: boolean; + footer: { invalidate: ReturnType }; + renderPlanArtifact: ReturnType; +} + +export const openPendingPlanDecision = Reflect.get(InteractiveMode.prototype, "openPendingPlanDecision") as ( + this: OpenPlanDecisionContext, + options?: { explicit?: boolean }, +) => void; + +export const startPlanContinuation = Reflect.get(InteractiveMode.prototype, "startPlanContinuation") as ( + this: PlanDecisionContext, + continuation: PlanContinuation, + onPromptOwned?: () => void, +) => Promise; + +export const handlePlanCommand = Reflect.get(InteractiveMode.prototype, "handlePlanCommand") as ( + this: PlanDecisionContext, + argument?: string, +) => Promise; + +export const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( + this: PlanSubmittedEventContext, + event: { type: "plan_submitted"; plan: PlanArtifact }, +) => Promise; + +export interface RunFlowContext { + init: ReturnType; + version: string; + isInitialized: boolean; + options: { + initialMessage?: string; + initialImages?: unknown[]; + initialMessages?: string[]; + }; + session: { + modelRuntime: { getError: () => undefined }; + pendingPlan?: PlanArtifact; + pendingPlanContinuation?: PlanContinuation; + }; + checkForPackageUpdates: ReturnType; + checkTmuxKeyboardSetup: ReturnType; + maybeWarnAboutAnthropicSubscriptionAuth: ReturnType; + showNewVersionNotification: ReturnType; + showWarning: ReturnType; + showError: ReturnType; + showStatus: ReturnType; + openPendingPlanDecision: ReturnType; + promptUser: ReturnType; + getUserInput: ReturnType; + editor: { getText: () => string; setText: ReturnType }; + ui: { requestRender: ReturnType }; +} + +export const runInteractive = Reflect.get(InteractiveMode.prototype, "run") as (this: RunFlowContext) => Promise; + +export interface RebindContext { + session: { pendingPlanContinuation?: PlanContinuation }; + activeEditorUi?: { preserveOnSessionRebind: boolean }; + unsubscribe?: () => void; + applyRuntimeSettings: ReturnType; + renderCurrentSessionState: ReturnType; + subscribeToAgent: ReturnType; + bindCurrentSessionExtensions: ReturnType; + updateAvailableProviderCount: ReturnType; + updateEditorBorderColor: ReturnType; + updateTerminalTitle: ReturnType; + showStatus: ReturnType; + openPendingPlanDecision: ReturnType; +} + +export const rebindCurrentSession = Reflect.get(InteractiveMode.prototype, "rebindCurrentSession") as ( + this: RebindContext, + options?: { renderBeforeBind?: boolean }, +) => Promise; + +export interface TreeNavigationStatusContext { + session: { pendingPlanContinuation?: PlanContinuation }; + showStatus: ReturnType; +} + +export const showTreeNavigationStatus = Reflect.get(InteractiveMode.prototype, "showTreeNavigationStatus") as ( + this: TreeNavigationStatusContext, +) => void; + +export interface StreamingSubmitContext { + defaultEditor: { onSubmit?: (text: string) => Promise }; + editor: { + addToHistory?: (text: string) => void; + setText: (text: string) => void; + }; + session: { + isCompacting: boolean; + isStreaming: boolean; + }; + promptUser: ReturnType; + updatePendingMessagesDisplay: ReturnType; + openPendingPlanDecision: ReturnType; + ui: { requestRender: ReturnType }; +} + +export const setupEditorSubmitHandler = Reflect.get(InteractiveMode.prototype, "setupEditorSubmitHandler") as ( + this: StreamingSubmitContext, +) => void; + +export interface AgentSettledEventContext { + isInitialized: boolean; + footer: { invalidate: ReturnType }; + checkShutdownRequested: ReturnType; + openPendingPlanDecision: ReturnType; +} + +export const handleAgentSettled = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( + this: AgentSettledEventContext, + event: { type: "agent_settled" }, +) => Promise; + +export interface RenderPlanEntryContext { + pendingTools: Map; + settingsManager: { getShowCacheMissNotices: () => boolean }; + sessionManager: { getEntries: () => SessionEntry[] }; + session: { modelRuntime: unknown }; + renderPlanArtifact: ReturnType; + ui: { requestRender: ReturnType }; +} + +export const renderSessionItems = Reflect.get(InteractiveMode.prototype, "renderSessionItems") as ( + this: RenderPlanEntryContext, + items: PlanEntry[], +) => void; + +export interface RenderSessionEntriesContext { + session: { pendingPlan: PlanArtifact | undefined }; + renderSessionItems: ReturnType; + renderPlanArtifact: ReturnType; +} + +export const renderSessionEntries = Reflect.get(InteractiveMode.prototype, "renderSessionEntries") as ( + this: RenderSessionEntriesContext, + entries: SessionEntry[], +) => void; + +export interface EditorUiContext { + activeEditorUi?: { + token: object; + cancel: () => void; + exclusive: boolean; + preserveOnSessionRebind: boolean; + }; + editorContainer: Container; + editor: Component; + ui: TUI; + restoreEditorUi: () => void; + cancelActiveEditorUi: (restoreEditor?: boolean) => void; + closeEditorUi: (token: object) => boolean; + replaceEditorUi: ( + token: object, + component: Component, + focus: Component, + options?: { cancel?: () => void; preserveOnSessionRebind?: boolean }, + ) => boolean; + cancelEditorUi: (token: object) => void; + mountEditorUi: ( + component: Component, + focus: Component, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, + ) => object; +} + +export const restoreEditorUi = Reflect.get(InteractiveMode.prototype, "restoreEditorUi") as ( + this: EditorUiContext, +) => void; +export const cancelActiveEditorUi = Reflect.get(InteractiveMode.prototype, "cancelActiveEditorUi") as ( + this: EditorUiContext, + restoreEditor?: boolean, +) => void; +export const closeEditorUi = Reflect.get(InteractiveMode.prototype, "closeEditorUi") as ( + this: EditorUiContext, + token: object, +) => boolean; +export const replaceEditorUi = Reflect.get(InteractiveMode.prototype, "replaceEditorUi") as ( + this: EditorUiContext, + token: object, + component: Component, + focus: Component, + options?: { cancel?: () => void; preserveOnSessionRebind?: boolean }, +) => boolean; +export const cancelEditorUi = Reflect.get(InteractiveMode.prototype, "cancelEditorUi") as ( + this: EditorUiContext, + token: object, +) => void; +export const mountEditorUi = Reflect.get(InteractiveMode.prototype, "mountEditorUi") as ( + this: EditorUiContext, + component: Component, + focus: Component, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, +) => object; +export const resolvePlanProfileSelection = Reflect.get(InteractiveMode.prototype, "resolvePlanProfileSelection") as ( + this: EditorUiContext, + proposal: ProfileSelectionProposal, + signal: AbortSignal, + handoffToken: object, + handoff: Component, + handoffCancel: () => void, +) => Promise; +export const showExtensionSelector = Reflect.get(InteractiveMode.prototype, "showExtensionSelector") as ( + this: EditorUiContext & { toggleToolOutputExpansion: () => void }, + title: string, + options: string[], + opts?: { signal?: AbortSignal }, +) => Promise; +export const showSelector = Reflect.get(InteractiveMode.prototype, "showSelector") as ( + this: EditorUiContext, + create: (done: () => void) => { component: Component; focus: Component; dispose?: () => void }, +) => void; +export const showExtensionCustom = Reflect.get(InteractiveMode.prototype, "showExtensionCustom") as ( + this: EditorUiContext & { keybindings: object }, + factory: (tui: TUI, theme: unknown, keybindings: object, done: (result: T) => void) => Component, + options?: { overlay?: boolean }, +) => Promise; + +export interface SessionRebindUiContext extends EditorUiContext { + session: { isStreaming: boolean }; + renderer: { hasOverlayEntries: boolean }; + cancelPendingPlanDecision: ReturnType; + clearExtensionTerminalInputListeners: ReturnType; + setExtensionFooter: ReturnType; + setExtensionHeader: ReturnType; + clearExtensionWidgets: ReturnType; + footerDataProvider: { clearExtensionStatuses: ReturnType }; + footer: { invalidate: ReturnType }; + autocompleteProviderWrappers: unknown[]; + setCustomEditorComponent: (factory: undefined) => void; + setupAutocompleteProvider: ReturnType; + defaultEditor: InputRecorder & { onExtensionShortcut?: (data: string) => void }; + updateTerminalTitle: ReturnType; + workingMessage?: string; + workingVisible: boolean; + setWorkingIndicator: ReturnType; + activeStatusIndicator?: { kind: string; setMessage: (message: string) => void }; + setHiddenThinkingLabel: ReturnType; + editorComponentFactory?: unknown; +} + +export const resetExtensionUI = Reflect.get(InteractiveMode.prototype, "resetExtensionUI") as ( + this: SessionRebindUiContext, + options?: { forSessionRebind?: boolean }, +) => void; +export const setCustomEditorComponent = Reflect.get(InteractiveMode.prototype, "setCustomEditorComponent") as ( + this: SessionRebindUiContext, + factory: undefined, +) => void; + +export interface LoginDialogContext extends EditorUiContext { + session: { model: undefined }; + loginProvider: ReturnType; + completeProviderAuthentication: ReturnType; + showError: ReturnType; +} + +export const showLoginDialog = Reflect.get(InteractiveMode.prototype, "showLoginDialog") as ( + this: LoginDialogContext, + providerId: string, + providerName: string, +) => Promise; + +export class InputRecorder implements Component, Focusable { + focused = false; + readonly inputs: string[] = []; + private text = ""; + + handleInput(data: string): void { + this.inputs.push(data); + } + + getText(): string { + return this.text; + } + + setText(text: string): void { + this.text = text; + } + + render(): string[] { + return ["EDITOR"]; + } + + invalidate(): void {} +} + +export function installEditorUiMethods( + context: T, +): T & EditorUiContext { + const editorUiContext = context as T & EditorUiContext; + editorUiContext.restoreEditorUi = () => restoreEditorUi.call(editorUiContext); + editorUiContext.cancelActiveEditorUi = (restoreEditor) => cancelActiveEditorUi.call(editorUiContext, restoreEditor); + editorUiContext.closeEditorUi = (token) => closeEditorUi.call(editorUiContext, token); + editorUiContext.replaceEditorUi = (token, component, focus, options) => + replaceEditorUi.call(editorUiContext, token, component, focus, options); + editorUiContext.cancelEditorUi = (token) => cancelEditorUi.call(editorUiContext, token); + editorUiContext.mountEditorUi = (component, focus, cancel, options) => + mountEditorUi.call(editorUiContext, component, focus, cancel, options); + return editorUiContext; +} + +export async function flushTui(ui: TUI, terminal: VirtualTerminal): Promise { + ui.requestRender(true); + await Promise.resolve(); + await terminal.waitForRender(); +} + +export function createProfileSelectionContext(width: number) { + const terminal = new VirtualTerminal(width, 24); + const ui = new TuiMainScreen(terminal); + const editor = new InputRecorder(); + const handoff = new InputRecorder(); + const editorContainer = new Container(); + editorContainer.addChild(editor); + ui.addChild(editorContainer); + ui.setFocus(editor); + const context = installEditorUiMethods({ + session: { isStreaming: false }, + editor, + editorContainer, + ui, + }); + const handoffCancel = vi.fn(); + const handoffToken = context.mountEditorUi(handoff, handoff, handoffCancel, { + exclusive: true, + preserveOnSessionRebind: true, + }); + return { terminal, ui, editor, handoff, context, handoffCancel, handoffToken }; +} + +export function createDecisionContext(selection: string | undefined): PlanDecisionContext { + const session: PlanDecisionSession = { + pendingPlan: PLAN, + resolvePlan: vi.fn(), + promptPlanContinuation: vi.fn(), + }; + const context: PlanDecisionContext = { + session, + runtimeHost: { + promptPlanContinuation: vi.fn( + (_continuation: unknown, options?: { preflightResult?: (success: boolean) => void }) => { + session.pendingPlanContinuation = undefined; + options?.preflightResult?.(true); + return Promise.resolve({ routing: "bypassed", activeProfileId: "general" }); + }, + ), + }, + planDecisionAbortController: undefined, + suppressedPlanDecisionId: undefined, + showExtensionSelector: vi.fn().mockResolvedValue(selection), + showStatus: vi.fn(), + showError: vi.fn(), + mountEditorUi: vi.fn(() => ({})), + closeEditorUi: vi.fn(() => true), + resolvePlanProfileSelection: vi.fn().mockResolvedValue({ cancelled: true }), + startPlanContinuation: async () => {}, + editor: { + getText: () => "", + setText: vi.fn(), + }, + ui: { requestRender: vi.fn() }, + }; + context.startPlanContinuation = (continuation, onPromptOwned) => + startPlanContinuation.call(context, continuation, onPromptOwned); + return context; +}