From 10cade6c69259ff8109aa1cc108399587289e56b Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sun, 16 Aug 2026 11:50:26 -0700 Subject: [PATCH 1/8] feat(coding-agent): accept instructions with plan command --- .../coding-agent/src/core/slash-commands.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 88 ++++++------------- .../interactive-mode-plan-resolution.test.ts | 40 ++++++++- .../test/interactive-mode-plan-test-utils.ts | 12 ++- .../coding-agent/test/slash-commands.test.ts | 6 +- 5 files changed, 77 insertions(+), 71 deletions(-) diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index c0f6e628a..41c12a9e4 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -19,7 +19,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, - { name: "plan", description: "Toggle plan mode", argumentHint: "[on|off]" }, + { name: "plan", description: "Start a planning prompt", argumentHint: "[instructions]" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1f4874ea8..4b40121ca 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -719,22 +719,6 @@ export class InteractiveMode { }; } - const planCommand = slashCommands.find((command) => command.name === "plan"); - if (planCommand) { - planCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { - const options = [ - { value: "on", label: "on", description: "Enable plan mode" }, - { value: "off", label: "off", description: "Disable plan mode" }, - ]; - return createFuzzyAutocompleteItems( - options, - prefix, - (option) => `${option.value} ${option.description}`, - (option) => option, - ); - }; - } - const loginCommand = slashCommands.find((command) => command.name === "login"); if (loginCommand) { loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { @@ -2752,58 +2736,35 @@ export class InteractiveMode { } } - private async handlePlanCommand(argument?: string): Promise { - if (argument !== undefined && argument !== "on" && argument !== "off") { - this.showError("Usage: /plan [on|off]"); - return; - } - + private async handlePlanCommand(instructions?: string): Promise { const continuation = this.session.pendingPlanContinuation; if (continuation) { - if (argument === undefined) { + if (instructions === undefined) { await this.startPlanContinuation(continuation); - return; + } else { + this.showError("Accepted plan implementation is waiting. Use /plan to retry it first."); } - this.showError("Accepted plan implementation is waiting. Use /plan to retry it first."); - return; + return undefined; } - if (argument === undefined && this.session.collaborationMode === "plan" && this.session.pendingPlan) { - this.openPendingPlanDecision({ explicit: true }); - return; + if (this.session.pendingPlan) { + if (instructions === undefined) { + this.openPendingPlanDecision({ explicit: true }); + } else { + this.showError("Resolve the pending plan before starting another planning prompt."); + } + return undefined; } - const targetMode = - argument === "on" - ? "plan" - : argument === "off" - ? "default" - : this.session.collaborationMode === "plan" - ? "default" - : "plan"; - try { - if (targetMode === "default" && this.session.pendingPlan) { - const plan = this.session.pendingPlan; - const confirmed = await this.showExtensionConfirm( - "Discard pending plan", - "Disable plan mode and discard the pending plan?", - ); - if (!confirmed) return; - this.session.resolvePlan(plan.id, "discard"); - this.showStatus("Plan discarded; plan mode disabled"); - return; - } - - if (this.session.collaborationMode === targetMode) { - this.showStatus(`Plan mode is already ${targetMode === "plan" ? "enabled" : "disabled"}`); - return; + if (this.session.collaborationMode !== "plan") { + this.session.setCollaborationMode("plan"); + this.showStatus("Plan mode enabled"); } - - this.session.setCollaborationMode(targetMode); - this.showStatus(`Plan mode ${targetMode === "plan" ? "enabled" : "disabled"}`); + return instructions; } catch (error: unknown) { - this.showError(`Failed to change plan mode: ${error instanceof Error ? error.message : String(error)}`); + this.showError(`Failed to enable plan mode: ${error instanceof Error ? error.message : String(error)}`); + return undefined; } } @@ -3242,12 +3203,6 @@ export class InteractiveMode { await this.handleModelCommand(searchTerm); return; } - if (text === "/plan" || text.startsWith("/plan ")) { - const argument = text.slice("/plan".length).trim() || undefined; - this.editor.setText(""); - await this.handlePlanCommand(argument); - return; - } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -3365,6 +3320,13 @@ export class InteractiveMode { await this.shutdown(); return; } + if (text === "/plan" || text.startsWith("/plan ")) { + const instructions = text.slice("/plan".length).trim() || undefined; + this.editor.setText(""); + const planningPrompt = await this.handlePlanCommand(instructions); + if (planningPrompt === undefined) return; + text = planningPrompt; + } // Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { diff --git a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts index e1da8707e..d8cddf711 100644 --- a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts @@ -128,8 +128,10 @@ describe("InteractiveMode plan resolution", () => { const reboundSession: PlanDecisionSession = { pendingPlan: undefined, pendingPlanContinuation: restoredContinuation, + collaborationMode: "default", resolvePlan: vi.fn(), promptPlanContinuation: vi.fn(), + setCollaborationMode: vi.fn(), }; context.session = reboundSession; context.runtimeHost.promptPlanContinuation.mockImplementation( @@ -151,19 +153,53 @@ describe("InteractiveMode plan resolution", () => { expect(reboundSession.pendingPlanContinuation).toBeUndefined(); }); - test.each(["on", "off"])("blocks /plan %s while an accepted continuation is pending", async (argument) => { + test("activates plan mode and returns slash-command instructions as the prompt", async () => { + const context = createDecisionContext(undefined); + context.session.pendingPlan = undefined; + context.session.collaborationMode = "default"; + + const prompt = await handlePlanCommand.call(context, "inspect the authentication flow"); + + expect(context.session.setCollaborationMode).toHaveBeenCalledWith("plan"); + expect(context.showStatus).toHaveBeenCalledWith("Plan mode enabled"); + expect(prompt).toBe("inspect the authentication flow"); + }); + + test("keeps bare /plan active instead of toggling plan mode off", async () => { + const context = createDecisionContext(undefined); + context.session.pendingPlan = undefined; + + const prompt = await handlePlanCommand.call(context); + + expect(context.session.setCollaborationMode).not.toHaveBeenCalled(); + expect(prompt).toBeUndefined(); + }); + + test.each(["on", "off"])("treats former /plan %s controls as planning instructions", async (instructions) => { + const context = createDecisionContext(undefined); + context.session.pendingPlan = undefined; + context.session.collaborationMode = "default"; + + const prompt = await handlePlanCommand.call(context, instructions); + + expect(context.session.setCollaborationMode).toHaveBeenCalledWith("plan"); + expect(prompt).toBe(instructions); + }); + + test("blocks new /plan instructions while an accepted continuation is pending", async () => { const context = createDecisionContext(undefined); const continuation = Object.freeze({ message: "Implement the accepted plan." }); context.session.pendingPlan = undefined; context.session.pendingPlanContinuation = continuation; - await handlePlanCommand.call(context, argument); + const prompt = await handlePlanCommand.call(context, "revise the implementation"); expect(context.runtimeHost.promptPlanContinuation).not.toHaveBeenCalled(); expect(context.showError).toHaveBeenCalledWith( "Accepted plan implementation is waiting. Use /plan to retry it first.", ); expect(context.session.pendingPlanContinuation).toBe(continuation); + expect(prompt).toBeUndefined(); }); test("does not leave a handoff loader mounted when durable plan acceptance fails", async () => { diff --git a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts index 3fb7d9bfd..a49fe9de8 100644 --- a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts +++ b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts @@ -15,9 +15,11 @@ export const PLAN: PlanArtifact = { export interface PlanDecisionSession { pendingPlan: PlanArtifact | undefined; pendingPlanContinuation?: PlanContinuation; + collaborationMode: "default" | "plan"; isStreaming?: boolean; resolvePlan: ReturnType; promptPlanContinuation: ReturnType; + setCollaborationMode: ReturnType; } export interface PlanDecisionContext { @@ -26,6 +28,7 @@ export interface PlanDecisionContext { planDecisionAbortController?: AbortController; suppressedPlanDecisionId?: string; showExtensionSelector: ReturnType; + openPendingPlanDecision: ReturnType; showStatus: ReturnType; showError: ReturnType; mountEditorUi: ReturnType; @@ -72,8 +75,8 @@ export const startPlanContinuation = Reflect.get(InteractiveMode.prototype, "sta export const handlePlanCommand = Reflect.get(InteractiveMode.prototype, "handlePlanCommand") as ( this: PlanDecisionContext, - argument?: string, -) => Promise; + instructions?: string, +) => Promise; export const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( this: PlanSubmittedEventContext, @@ -386,8 +389,12 @@ export function createProfileSelectionContext(width: number) { export function createDecisionContext(selection: string | undefined): PlanDecisionContext { const session: PlanDecisionSession = { pendingPlan: PLAN, + collaborationMode: "plan", resolvePlan: vi.fn(), promptPlanContinuation: vi.fn(), + setCollaborationMode: vi.fn((mode: "default" | "plan") => { + session.collaborationMode = mode; + }), }; const context: PlanDecisionContext = { session, @@ -403,6 +410,7 @@ export function createDecisionContext(selection: string | undefined): PlanDecisi planDecisionAbortController: undefined, suppressedPlanDecisionId: undefined, showExtensionSelector: vi.fn().mockResolvedValue(selection), + openPendingPlanDecision: vi.fn(), showStatus: vi.fn(), showError: vi.fn(), mountEditorUi: vi.fn(() => ({})), diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts index 2f3c3d37d..c75704239 100644 --- a/packages/coding-agent/test/slash-commands.test.ts +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -18,11 +18,11 @@ describe("BUILTIN_SLASH_COMMANDS", () => { }); }); - it("registers plan mode with optional on/off arguments", () => { + it("registers plan mode with optional instructions", () => { expect(BUILTIN_SLASH_COMMANDS).toContainEqual({ name: "plan", - description: "Toggle plan mode", - argumentHint: "[on|off]", + description: "Start a planning prompt", + argumentHint: "[instructions]", }); }); }); From 1af65890064526c4e3c7e2ced67d85b9f7f4e44f Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sun, 16 Aug 2026 11:53:41 -0700 Subject: [PATCH 2/8] feat(coding-agent): select thinking effort with model --- .../components/settings-selector.ts | 36 --------- .../src/modes/interactive/interactive-mode.ts | 49 ++++++++---- .../interactive-mode-model-thinking.test.ts | 77 +++++++++++++++++++ .../test/settings-selector.test.ts | 14 +++- 4 files changed, 126 insertions(+), 50 deletions(-) create mode 100644 packages/coding-agent/test/interactive-mode-model-thinking.test.ts diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index f0eb3111f..3e8807fd6 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,4 +1,3 @@ -import type { ThinkingLevel } from "@earendil-works/diffex-agent-core"; import type { Transport } from "@earendil-works/diffex-ai"; import { type Component, @@ -35,16 +34,6 @@ const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { maxPrimaryColumnWidth: 32, }; -const THINKING_DESCRIPTIONS: Record = { - off: "No reasoning", - minimal: "Very brief reasoning (~1k tokens)", - low: "Light reasoning (~2k tokens)", - medium: "Moderate reasoning (~8k tokens)", - high: "Deep reasoning (~16k tokens)", - xhigh: "Extra-high reasoning (~32k tokens)", - max: "Maximum reasoning", -}; - const DEFAULT_PROJECT_TRUST_LABELS: Record = { ask: "Ask", always: "Always trust", @@ -66,8 +55,6 @@ export interface SettingsConfig { followUpMode: "all" | "one-at-a-time"; transport: Transport; httpIdleTimeoutMs: number; - thinkingLevel: ThinkingLevel; - availableThinkingLevels: ThinkingLevel[]; currentTheme: string; terminalTheme: TerminalTheme; availableThemes: string[]; @@ -102,7 +89,6 @@ export interface SettingsCallbacks { onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; - onThinkingLevelChange: (level: ThinkingLevel) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; @@ -607,28 +593,6 @@ export class SettingsSelectorComponent extends Container { () => done(), ), }, - { - id: "thinking", - label: "Thinking level", - description: "Reasoning depth for thinking-capable models", - currentValue: config.thinkingLevel, - submenu: (currentValue, done) => - new SelectSubmenu( - "Thinking Level", - "Select reasoning depth for thinking-capable models", - config.availableThinkingLevels.map((level) => ({ - value: level, - label: level, - description: THINKING_DESCRIPTIONS[level], - })), - currentValue, - (value) => { - callbacks.onThinkingLevelChange(value as ThinkingLevel); - done(value); - }, - () => done(), - ), - }, { id: "tui-mode", label: "TUI mode", diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4b40121ca..40ba7a170 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -169,6 +169,7 @@ import { WorkingStatusIndicator, } from "./components/status-indicator.ts"; import { SubagentsComponent } from "./components/subagents.ts"; +import { ThinkingSelectorComponent } from "./components/thinking-selector.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; @@ -4738,8 +4739,6 @@ export class InteractiveMode { followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), - thinkingLevel: this.session.thinkingLevel, - availableThinkingLevels: this.session.getAvailableThinkingLevels(), currentTheme: this.settingsManager.getThemeSetting() || "dark", terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), @@ -4807,11 +4806,6 @@ export class InteractiveMode { configureHttpDispatcher(timeoutMs); this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); }, - onThinkingLevelChange: (level) => { - this.session.setThinkingLevel(level); - this.footer.invalidate(); - this.updateEditorBorderColor(); - }, onThemeChange: (themeSetting) => { this.settingsManager.setTheme(themeSetting); void this.themeController.applyFromSettings(); @@ -4943,9 +4937,7 @@ export class InteractiveMode { await this.session.setModel(model); this.footer.invalidate(); this.updateEditorBorderColor(); - this.showStatus(`Model: ${model.id}`); - void this.maybeWarnAboutAnthropicSubscriptionAuth(model); - this.checkDaxnutsEasterEgg(model); + this.showModelThinkingSelector(model); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } @@ -5080,6 +5072,39 @@ export class InteractiveMode { }); } + private completeModelSelection(model: Model): void { + const thinking = model.reasoning ? ` (thinking: ${this.session.thinkingLevel})` : ""; + this.showStatus(`Model: ${model.id}${thinking}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + this.checkDaxnutsEasterEgg(model); + } + + private showModelThinkingSelector(model: Model): void { + if (!model.reasoning) { + this.completeModelSelection(model); + return; + } + + this.showSelector((done) => { + const selector = new ThinkingSelectorComponent( + this.session.thinkingLevel, + this.session.getAvailableThinkingLevels(), + (level) => { + this.session.setThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + done(); + this.completeModelSelection(model); + }, + () => { + done(); + this.completeModelSelection(model); + }, + ); + return { component: selector, focus: selector.getSelectList() }; + }); + } + private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( @@ -5094,9 +5119,7 @@ export class InteractiveMode { this.footer.invalidate(); this.updateEditorBorderColor(); done(); - this.showStatus(`Model: ${model.id}`); - void this.maybeWarnAboutAnthropicSubscriptionAuth(model); - this.checkDaxnutsEasterEgg(model); + this.showModelThinkingSelector(model); } catch (error) { done(); this.showError(error instanceof Error ? error.message : String(error)); diff --git a/packages/coding-agent/test/interactive-mode-model-thinking.test.ts b/packages/coding-agent/test/interactive-mode-model-thinking.test.ts new file mode 100644 index 000000000..b26da19bb --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-model-thinking.test.ts @@ -0,0 +1,77 @@ +import type { ThinkingLevel } from "@earendil-works/diffex-agent-core"; +import { getModel } from "@earendil-works/diffex-ai/compat"; +import type { Component } from "@earendil-works/diffex-tui"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { ThinkingSelectorComponent } from "../src/modes/interactive/components/thinking-selector.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +const reasoningModel = getModel("anthropic", "claude-sonnet-4-5")!; + +type ThinkingSelectionContext = { + session: { + thinkingLevel: ThinkingLevel; + getAvailableThinkingLevels: () => ThinkingLevel[]; + setThinkingLevel: ReturnType; + }; + footer: { invalidate: ReturnType }; + showSelector: (create: (done: () => void) => { component: Component; focus: Component }) => void; + completeModelSelection: ReturnType; + updateEditorBorderColor: ReturnType; +}; + +const showModelThinkingSelector = Reflect.get(InteractiveMode.prototype, "showModelThinkingSelector") as ( + this: ThinkingSelectionContext, + model: typeof reasoningModel, +) => void; + +beforeAll(() => initTheme("dark")); + +describe("interactive model thinking selection", () => { + test("opens thinking effort after selecting a reasoning model", () => { + let selector: ThinkingSelectorComponent | undefined; + const done = vi.fn(); + const context: ThinkingSelectionContext = { + session: { + thinkingLevel: "medium", + getAvailableThinkingLevels: () => ["off", "medium", "high"], + setThinkingLevel: vi.fn(), + }, + footer: { invalidate: vi.fn() }, + showSelector: (create) => { + selector = create(done).component as ThinkingSelectorComponent; + }, + completeModelSelection: vi.fn(), + updateEditorBorderColor: vi.fn(), + }; + + showModelThinkingSelector.call(context, reasoningModel); + selector?.getSelectList().onSelect?.({ value: "high", label: "high" }); + + expect(context.session.setThinkingLevel).toHaveBeenCalledWith("high"); + expect(context.footer.invalidate).toHaveBeenCalledOnce(); + expect(context.updateEditorBorderColor).toHaveBeenCalledOnce(); + expect(done).toHaveBeenCalledOnce(); + expect(context.completeModelSelection).toHaveBeenCalledWith(reasoningModel); + }); + + test("skips thinking effort for a model without reasoning support", () => { + const model = { ...reasoningModel, id: "plain-model", reasoning: false }; + const context: ThinkingSelectionContext = { + session: { + thinkingLevel: "off", + getAvailableThinkingLevels: () => ["off"], + setThinkingLevel: vi.fn(), + }, + footer: { invalidate: vi.fn() }, + showSelector: vi.fn(), + completeModelSelection: vi.fn(), + updateEditorBorderColor: vi.fn(), + }; + + showModelThinkingSelector.call(context, model); + + expect(context.showSelector).not.toHaveBeenCalled(); + expect(context.completeModelSelection).toHaveBeenCalledWith(model); + }); +}); diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index ee59ac724..6b0b6be07 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -7,6 +7,7 @@ import { SettingsSelectorComponent, } from "../src/modes/interactive/components/settings-selector.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; describe("SettingsSelectorComponent", () => { beforeAll(() => { @@ -14,13 +15,24 @@ describe("SettingsSelectorComponent", () => { setKeybindings(new KeybindingsManager()); }); + it("keeps thinking effort out of settings", () => { + const selector = new SettingsSelectorComponent( + { + warnings: {}, + availableThemes: [], + } as unknown as SettingsConfig, + {} as SettingsCallbacks, + ); + + expect(stripAnsi(selector.render(120).join("\n"))).not.toContain("Thinking level"); + }); + it("cycles through fullscreen scrollbar modes", () => { const onChange = vi.fn(); const selector = new SettingsSelectorComponent( { fullscreenScrollbar: "auto", warnings: {}, - availableThinkingLevels: [], availableThemes: [], } as unknown as SettingsConfig, { onFullscreenScrollbarChange: onChange } as unknown as SettingsCallbacks, From aa32913112adace01d57f64b32d96c217bdf76e2 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sun, 16 Aug 2026 11:56:30 -0700 Subject: [PATCH 3/8] feat(coding-agent): toggle plan mode with shift tab --- .../src/core/extensions/runner.ts | 2 +- packages/coding-agent/src/core/keybindings.ts | 7 ++- .../src/modes/interactive/interactive-mode.ts | 54 +++++++++++++------ .../src/modes/interactive/theme/dark.json | 3 +- .../src/modes/interactive/theme/light.json | 3 +- .../modes/interactive/theme/theme-schema.json | 6 ++- .../src/modes/interactive/theme/theme.ts | 12 ++++- .../test/interactive-mode-plan-editor.test.ts | 48 +++++++++++++++++ .../interactive-mode-plan-resolution.test.ts | 29 ++++++++++ .../test/interactive-mode-plan-test-utils.ts | 6 +++ .../test/keybindings-migration.test.ts | 7 +++ .../coding-agent/test/theme-detection.test.ts | 4 ++ 12 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/interactive-mode-plan-editor.test.ts diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index c04e56158..fec93e98e 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -73,7 +73,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.clear", "app.exit", "app.suspend", - "app.thinking.cycle", + "app.plan.toggle", "app.model.cycleForward", "app.model.cycleBackward", "app.model.select", diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 800f4482a..e8ccc0235 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -15,7 +15,7 @@ export interface AppKeybindings { "app.clear": true; "app.exit": true; "app.suspend": true; - "app.thinking.cycle": true; + "app.plan.toggle": true; "app.model.cycleForward": true; "app.model.cycleBackward": true; "app.model.select": true; @@ -70,9 +70,9 @@ export const KEYBINDINGS = { defaultKeys: process.platform === "win32" ? [] : "ctrl+z", description: "Suspend to background", }, - "app.thinking.cycle": { + "app.plan.toggle": { defaultKeys: "shift+tab", - description: "Cycle thinking level", + description: "Toggle plan mode", }, "app.model.cycleForward": { defaultKeys: "ctrl+p", @@ -242,7 +242,6 @@ const KEYBINDING_NAME_MIGRATIONS = { clear: "app.clear", exit: "app.exit", suspend: "app.suspend", - cycleThinkingLevel: "app.thinking.cycle", cycleModelForward: "app.model.cycleForward", cycleModelBackward: "app.model.cycleBackward", selectModel: "app.model.select", diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 40ba7a170..0578c71cf 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2737,6 +2737,39 @@ export class InteractiveMode { } } + private async togglePlanMode(): Promise { + const continuation = this.session.pendingPlanContinuation; + if (continuation) { + this.showError("Accepted plan implementation is waiting. Use /plan to retry it first."); + return; + } + + try { + if (this.session.collaborationMode === "plan") { + if (this.session.pendingPlan) { + const plan = this.session.pendingPlan; + const confirmed = await this.showExtensionConfirm( + "Discard pending plan", + "Disable plan mode and discard the pending plan?", + ); + if (!confirmed) return; + this.session.resolvePlan(plan.id, "discard"); + this.showStatus("Plan discarded; plan mode disabled"); + return; + } + + this.session.setCollaborationMode("default"); + this.showStatus("Plan mode disabled"); + return; + } + + this.session.setCollaborationMode("plan"); + this.showStatus("Plan mode enabled"); + } catch (error: unknown) { + this.showError(`Failed to toggle plan mode: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async handlePlanCommand(instructions?: string): Promise { const continuation = this.session.pendingPlanContinuation; if (continuation) { @@ -3096,7 +3129,7 @@ export class InteractiveMode { this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); this.defaultEditor.onCtrlD = () => this.handleCtrlD(); this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ()); - this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel()); + this.defaultEditor.onAction("app.plan.toggle", () => void this.togglePlanMode()); this.defaultEditor.onAction("app.model.cycleForward", () => this.cycleModel("forward")); this.defaultEditor.onAction("app.model.cycleBackward", () => this.cycleModel("backward")); @@ -3429,7 +3462,7 @@ export class InteractiveMode { break; case "collaboration_mode_changed": - this.ui.requestRender(); + this.updateEditorBorderColor(); break; case "plan_submitted": @@ -4350,23 +4383,14 @@ export class InteractiveMode { private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); + } else if (this.session.collaborationMode === "plan") { + this.editor.borderColor = theme.getPlanModeBorderColor(); } else { this.editor.borderColor = getEditorTheme().borderColor; } this.ui.requestRender(); } - private cycleThinkingLevel(): void { - const newLevel = this.session.cycleThinkingLevel(); - if (newLevel === undefined) { - this.showStatus("Current model does not support thinking"); - } else { - this.footer.invalidate(); - this.updateEditorBorderColor(); - this.showStatus(`Thinking level: ${newLevel}`); - } - } - private async cycleModel(direction: "forward" | "backward"): Promise { try { const result = await this.session.cycleModel(direction); @@ -6508,7 +6532,7 @@ export class InteractiveMode { const clear = this.getAppKeyDisplay("app.clear"); const exit = this.getAppKeyDisplay("app.exit"); const suspend = this.getAppKeyDisplay("app.suspend"); - const cycleThinkingLevel = this.getAppKeyDisplay("app.thinking.cycle"); + const togglePlanMode = this.getAppKeyDisplay("app.plan.toggle"); const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward"); const selectModel = this.getAppKeyDisplay("app.model.select"); const expandTools = this.getAppKeyDisplay("app.tools.expand"); @@ -6553,7 +6577,7 @@ export class InteractiveMode { | \`${clear}\` | Clear editor (first) / exit (second) | | \`${exit}\` | Exit (when editor is empty) | | \`${suspend}\` | Suspend to background | -| \`${cycleThinkingLevel}\` | Cycle thinking level | +| \`${togglePlanMode}\` | Toggle plan mode | | \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models | | \`${selectModel}\` | Open model selector | | \`${expandTools}\` | Toggle tool output expansion | diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index cb64f9b62..9c54d11d0 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -84,7 +84,8 @@ "thinkingXhigh": "#d183e8", "thinkingMax": "#ff5fff", - "bashMode": "red" + "bashMode": "red", + "planMode": "#6FB01C" }, "export": { "pageBg": "#18181e", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index e2dea33a9..b82bbfbb4 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -83,7 +83,8 @@ "thinkingXhigh": "#8b008b", "thinkingMax": "#af005f", - "bashMode": "red" + "bashMode": "red", + "planMode": "#6FB01C" }, "export": { "pageBg": "#f8f8f8", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 2dbc3ff5d..0d34c074a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (editorBorder, thinkingMax, scrollbarThumb, startupIntroText, startupIntroAccent, selectionAccent, and autocompleteSelected are optional and use compatible fallbacks)", + "description": "Theme color definitions (editorBorder, planMode, thinkingMax, scrollbarThumb, startupIntroText, startupIntroAccent, selectionAccent, and autocompleteSelected are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -320,6 +320,10 @@ "bashMode": { "$ref": "#/$defs/colorValue", "description": "Editor border color in bash mode" + }, + "planMode": { + "$ref": "#/$defs/colorValue", + "description": "Editor border color in plan mode (optional, defaults to #6FB01C)" } }, "additionalProperties": false diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 7c6d561e2..0ae071145 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -97,8 +97,9 @@ const ThemeJsonSchema = Type.Object({ thinkingHigh: ColorValueSchema, thinkingXhigh: ColorValueSchema, thinkingMax: Type.Optional(ColorValueSchema), - // Bash Mode (1 color) + // Mode Borders (1 required, 1 optional) bashMode: ColorValueSchema, + planMode: Type.Optional(ColorValueSchema), }), export: Type.Optional( Type.Object({ @@ -164,7 +165,8 @@ export type ThemeColor = | "thinkingHigh" | "thinkingXhigh" | "thinkingMax" - | "bashMode"; + | "bashMode" + | "planMode"; export type ThemeBg = | "selectedBg" @@ -340,6 +342,7 @@ function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors selectionAccent: ColorValue; autocompleteSelected: ColorValue; editorBorder: ColorValue; + planMode: ColorValue; } { const startupIntroAccent = colors.startupIntroAccent ?? colors.mdLink; const selectionAccent = colors.selectionAccent ?? colors.autocompleteSelected ?? startupIntroAccent; @@ -352,6 +355,7 @@ function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors selectionAccent, autocompleteSelected: colors.autocompleteSelected ?? selectionAccent, editorBorder: colors.editorBorder ?? colors.borderMuted, + planMode: colors.planMode ?? "#6FB01C", }; } @@ -466,6 +470,10 @@ export class Theme { getBashModeBorderColor(): (str: string) => string { return (str: string) => this.fg("bashMode", str); } + + getPlanModeBorderColor(): (str: string) => string { + return (str: string) => this.fg("planMode", str); + } } // ============================================================================ diff --git a/packages/coding-agent/test/interactive-mode-plan-editor.test.ts b/packages/coding-agent/test/interactive-mode-plan-editor.test.ts new file mode 100644 index 000000000..91425e11b --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-plan-editor.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { getEditorTheme, initTheme, theme } from "../src/modes/interactive/theme/theme.ts"; + +type EditorBorderContext = { + isBashMode: boolean; + session: { collaborationMode: "default" | "plan" }; + editor: { borderColor?: (text: string) => string }; + ui: { requestRender: ReturnType }; +}; + +const updateEditorBorderColor = Reflect.get(InteractiveMode.prototype, "updateEditorBorderColor") as ( + this: EditorBorderContext, +) => void; + +beforeAll(() => initTheme("dark")); + +describe("plan mode editor border", () => { + test("uses plan green while plan mode is active", () => { + const context: EditorBorderContext = { + isBashMode: false, + session: { collaborationMode: "plan" }, + editor: {}, + ui: { requestRender: vi.fn() }, + }; + + updateEditorBorderColor.call(context); + + expect(context.editor.borderColor?.("line")).toBe(theme.getPlanModeBorderColor()("line")); + }); + + test("keeps bash mode ahead of plan mode and restores the normal border", () => { + const context: EditorBorderContext = { + isBashMode: true, + session: { collaborationMode: "plan" }, + editor: {}, + ui: { requestRender: vi.fn() }, + }; + + updateEditorBorderColor.call(context); + expect(context.editor.borderColor?.("line")).toBe(theme.getBashModeBorderColor()("line")); + + context.isBashMode = false; + context.session.collaborationMode = "default"; + updateEditorBorderColor.call(context); + expect(context.editor.borderColor?.("line")).toBe(getEditorTheme().borderColor("line")); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts index d8cddf711..7c67e7aab 100644 --- a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts @@ -11,6 +11,7 @@ import { type StreamingSubmitContext, setupEditorSubmitHandler, showPendingPlanDecision, + togglePlanMode, } from "./interactive-mode-plan-test-utils.ts"; beforeAll(() => initTheme("dark")); @@ -153,6 +154,34 @@ describe("InteractiveMode plan resolution", () => { expect(reboundSession.pendingPlanContinuation).toBeUndefined(); }); + test("toggles plan mode on and off for the keyboard shortcut", async () => { + const context = createDecisionContext(undefined); + context.session.pendingPlan = undefined; + context.session.collaborationMode = "default"; + + await togglePlanMode.call(context); + expect(context.session.setCollaborationMode).toHaveBeenLastCalledWith("plan"); + expect(context.showStatus).toHaveBeenLastCalledWith("Plan mode enabled"); + + await togglePlanMode.call(context); + expect(context.session.setCollaborationMode).toHaveBeenLastCalledWith("default"); + expect(context.showStatus).toHaveBeenLastCalledWith("Plan mode disabled"); + }); + + test("confirms before the keyboard shortcut discards a pending plan", async () => { + const context = createDecisionContext(undefined); + context.showExtensionConfirm.mockResolvedValue(true); + + await togglePlanMode.call(context); + + expect(context.showExtensionConfirm).toHaveBeenCalledWith( + "Discard pending plan", + "Disable plan mode and discard the pending plan?", + ); + expect(context.session.resolvePlan).toHaveBeenCalledWith(PLAN.id, "discard"); + expect(context.showStatus).toHaveBeenLastCalledWith("Plan discarded; plan mode disabled"); + }); + test("activates plan mode and returns slash-command instructions as the prompt", async () => { const context = createDecisionContext(undefined); context.session.pendingPlan = undefined; diff --git a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts index a49fe9de8..9463adeb3 100644 --- a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts +++ b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts @@ -28,6 +28,7 @@ export interface PlanDecisionContext { planDecisionAbortController?: AbortController; suppressedPlanDecisionId?: string; showExtensionSelector: ReturnType; + showExtensionConfirm: ReturnType; openPendingPlanDecision: ReturnType; showStatus: ReturnType; showError: ReturnType; @@ -73,6 +74,10 @@ export const startPlanContinuation = Reflect.get(InteractiveMode.prototype, "sta onPromptOwned?: () => void, ) => Promise; +export const togglePlanMode = Reflect.get(InteractiveMode.prototype, "togglePlanMode") as ( + this: PlanDecisionContext, +) => Promise; + export const handlePlanCommand = Reflect.get(InteractiveMode.prototype, "handlePlanCommand") as ( this: PlanDecisionContext, instructions?: string, @@ -410,6 +415,7 @@ export function createDecisionContext(selection: string | undefined): PlanDecisi planDecisionAbortController: undefined, suppressedPlanDecisionId: undefined, showExtensionSelector: vi.fn().mockResolvedValue(selection), + showExtensionConfirm: vi.fn().mockResolvedValue(false), openPendingPlanDecision: vi.fn(), showStatus: vi.fn(), showError: vi.fn(), diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts index f393687c4..663f8c3f1 100644 --- a/packages/coding-agent/test/keybindings-migration.test.ts +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -22,6 +22,13 @@ describe("keybindings migration", () => { return agentDir; } + it("binds Shift+Tab to plan mode instead of thinking effort", () => { + const keybindings = new KeybindingsManager(); + + expect(keybindings.getKeys("app.plan.toggle")).toEqual(["shift+tab"]); + expect(keybindings.getEffectiveConfig()).not.toHaveProperty("app.thinking.cycle"); + }); + it("rewrites old key names to namespaced ids", () => { const agentDir = createAgentDir({ cursorUp: ["up", "ctrl+p"], diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 503e1aaaa..ed3f3af5f 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -254,6 +254,7 @@ describe("editor border theme", () => { expect(getEditorTheme().borderColor("line")).toBe(`${expected}line\x1b[39m`); expect(theme.getThinkingBorderColor("low")("line")).not.toBe(`${expected}line\x1b[39m`); expect(theme.getBashModeBorderColor()("line")).toBe("\x1b[38;2;204;102;102mline\x1b[39m"); + expect(theme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;111;176;28mline\x1b[39m"); }); it("uses the fixed light theme color", () => { @@ -262,6 +263,7 @@ describe("editor border theme", () => { expect(theme.getFgAnsi("editorBorder")).toBe("\x1b[38;2;60;89;122m"); expect(theme.getBashModeBorderColor()("line")).toBe("\x1b[38;2;170;85;85mline\x1b[39m"); + expect(theme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;111;176;28mline\x1b[39m"); }); it("falls back to borderMuted for existing custom themes", () => { @@ -270,6 +272,7 @@ describe("editor border theme", () => { ) as { name: string; colors: Record }; themeJson.name = "legacy-editor-border-theme"; delete themeJson.colors.editorBorder; + delete themeJson.colors.planMode; const testDir = mkdtempSync(join(tmpdir(), "diffex-editor-border-theme-")); tempDirs.push(testDir); const themePath = join(testDir, "legacy-editor-border-theme.json"); @@ -278,5 +281,6 @@ describe("editor border theme", () => { const legacyTheme = loadThemeFromPath(themePath, "truecolor"); expect(legacyTheme.getFgAnsi("editorBorder")).toBe(legacyTheme.getFgAnsi("borderMuted")); + expect(legacyTheme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;111;176;28mline\x1b[39m"); }); }); From a15b12e85c2f4c847426166622dea6cfdb16e73d Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sun, 16 Aug 2026 11:59:49 -0700 Subject: [PATCH 4/8] feat(coding-agent): document plan mode workflow --- packages/coding-agent/README.md | 13 +++++++------ packages/coding-agent/docs/extensions.md | 4 ++-- packages/coding-agent/docs/keybindings.md | 6 +++--- packages/coding-agent/docs/quickstart.md | 2 +- packages/coding-agent/docs/settings.md | 2 ++ packages/coding-agent/docs/themes.md | 16 +++++++++------- packages/coding-agent/docs/tui.md | 2 +- packages/coding-agent/docs/usage.md | 13 +++++++------ .../src/modes/interactive/theme/theme.ts | 9 +++++++-- 9 files changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index bf1d37800..50d77c2a3 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -147,14 +147,15 @@ The interface from top to bottom: - **Startup header** - Shows shortcuts (`/hotkeys` for all), loaded AGENTS.md files, prompt templates, skills, and extensions - **Messages** - Your messages, assistant responses, tool calls and results, notifications, errors, and extension UI -- **Editor** - Where you type; border color indicates thinking level +- **Editor** - Where you type; border color indicates plan mode or shell mode - **Footer** - The built-in footer keeps the current model, thinking level, and context usage visible. The editor can be temporarily replaced by other UI, like built-in `/settings` or custom UI from extensions (e.g., a Q&A tool that lets the user answer model questions in a structured format). [Extensions](#extensions) can also replace the editor, add widgets above/below it, a status line, custom footer, or overlays. ### Plan Mode -Run `diffex --plan` to start an interactive session in plan mode, or use `/plan [on|off]` while Diffex is idle. +Run `diffex --plan` to start an interactive session in plan mode, submit `/plan [instructions]`, or press Shift+Tab and type instructions in the editor. +Shift+Tab toggles plan mode while the editor border uses `#6FB01C` to show that planning is active. Plan mode exposes `submit_plan` and the canonical subset of `read`, `grep`, `find`, and `ls` permitted by caller restrictions. The agent can inspect the repository, but it cannot run commands, edit files, or call extension and SDK tools. `submit_plan` is intrinsic to plan mode and remains available even when caller tool filters disable every inspection tool. @@ -186,10 +187,10 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist |---------|-------------| | `/login`, `/logout` | Manage provider credentials | | [`/llama`](docs/llama-cpp.md) | Download, load, and unload llama.cpp router models | -| `/model` | Switch models | +| `/model` | Switch models, then select thinking effort | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | -| `/plan [on\|off]` | Toggle plan mode, set it explicitly, or reopen a pending plan | -| `/settings` | Thinking level, theme, message delivery, transport | +| `/plan [instructions]` | Activate plan mode and optionally submit planning instructions, or reopen/retry a pending plan | +| `/settings` | Theme, message delivery, transport | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | | `/name ` | Set session display name | @@ -226,7 +227,7 @@ See `/hotkeys` for the full list. Customize via `~/.diffex/agent/keybindings.jso | Escape twice | Open `/tree` | | Ctrl+L | Open model selector | | Ctrl+P / Shift+Ctrl+P | Cycle scoped models forward/backward | -| Shift+Tab | Cycle thinking level | +| Shift+Tab | Toggle plan mode | | Ctrl+O | Collapse/expand tool output | | Ctrl+T | Collapse/expand thinking blocks | | Ctrl+X | Copy the last assistant message | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 73eabf227..2d51ffe32 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -340,7 +340,7 @@ user sends another prompt ◄───────────────── ├─► thinking_level_select (if model change changes/clamps thinking level) └─► model_select -thinking level changes (settings, keybinding, diffex.setThinkingLevel()) +thinking level changes (/model effort selection, diffex.setThinkingLevel()) └─► thinking_level_select exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) @@ -744,7 +744,7 @@ diffex.on("thinking_level_select", async (event, ctx) => { }); ``` -Use this to update extension UI when `diffex.setThinkingLevel()`, model changes, or built-in thinking-level controls change the active thinking level. +Use this to update extension UI when `diffex.setThinkingLevel()`, model changes, or `/model` effort selection changes the active thinking level. ### Tool Events diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 2b483e724..1a0fb7f9f 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -136,14 +136,14 @@ This routing remains configurable through the ordinary action bindings. For exam | `app.session.delete` | `ctrl+d` | Delete session | | `app.session.deleteNoninvasive` | `ctrl+backspace` | Delete session when query is empty | -### Models and Thinking +### Models, Planning, and Thinking | Keybinding id | Default | Description | |--------|---------|-------------| -| `app.model.select` | `ctrl+l` | Open model selector | +| `app.model.select` | `ctrl+l` | Open model selector, followed by thinking effort selection | | `app.model.cycleForward` | `ctrl+p` | Cycle to next model | | `app.model.cycleBackward` | `shift+ctrl+p` | Cycle to previous model | -| `app.thinking.cycle` | `shift+tab` | Cycle thinking level | +| `app.plan.toggle` | `shift+tab` | Toggle plan mode | | `app.thinking.toggle` | `ctrl+t` | Collapse or expand thinking blocks | ### Display and Message Queue diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index ec42085b2..459c80e47 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -130,7 +130,7 @@ The command output is sent to the model. Use `!!command` to run a command withou ### Switch models -Use `/model` or Ctrl+L to choose a model. Use Shift+Tab to cycle thinking level. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models. +Use `/model` or Ctrl+L to choose a model, then select its thinking effort. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models. Shift+Tab toggles plan mode. ### Continue later diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index ecc27c812..698eeae07 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -25,6 +25,8 @@ Use `/trust` in interactive mode to save a project trust decision for future ses ### Model & Thinking +In interactive mode, `/model` selects both the model and its thinking effort. + | Setting | Type | Default | Description | |---------|------|---------|-------------| | `defaultProvider` | string | - | Default provider (e.g., `"anthropic"`, `"openai"`) | diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index ee1c5862c..b4538f839 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -114,7 +114,8 @@ vim ~/.diffex/agent/themes/my-theme.json "thinkingHigh": "#ff00ff", "thinkingXhigh": "#ff0000", "thinkingMax": "#ff0088", - "bashMode": "#ffaa00" + "bashMode": "#ffaa00", + "planMode": "#6FB01C" } } ``` @@ -145,15 +146,15 @@ vim ~/.diffex/agent/themes/my-theme.json - `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. - `colors` must define all 51 required tokens. - `thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, `selectionAccent`, and `autocompleteSelected` are optional compatibility tokens with fallbacks. + `planMode`, `thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, `selectionAccent`, and `autocompleteSelected` are optional compatibility tokens with fallbacks. The `$schema` field enables editor auto-completion and validation. ## Color Tokens Every theme must define all 51 required color tokens. -`thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, `selectionAccent`, and `autocompleteSelected` are optional for compatibility with existing themes. -When omitted, they use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, `autocompleteSelected` or `startupIntroAccent`, and `selectionAccent`, respectively. +`planMode`, `thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, `selectionAccent`, and `autocompleteSelected` are optional for compatibility with existing themes. +When omitted, `planMode` defaults to `#6FB01C`; the remaining tokens use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, `autocompleteSelected` or `startupIntroAccent`, and `selectionAccent`, respectively. ### Core UI (11 colors) @@ -234,9 +235,9 @@ When omitted, they use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, `autocom | `syntaxOperator` | Operators | | `syntaxPunctuation` | Punctuation | -### Thinking Level Borders (6 required, 1 optional) +### Thinking Level Colors (6 required, 1 optional) -Editor border colors indicating thinking level (visual hierarchy from subtle to prominent): +Legacy thinking-level colors exposed through the theme API for extensions. The built-in editor no longer changes its border by thinking effort: | Token | Purpose | |-------|---------| @@ -248,11 +249,12 @@ Editor border colors indicating thinking level (visual hierarchy from subtle to | `thinkingXhigh` | Extra high thinking | | `thinkingMax` | Maximum thinking; optional, falls back to `thinkingXhigh` | -### Bash Mode (1 color) +### Mode Borders (1 required, 1 optional) | Token | Purpose | |-------|---------| | `bashMode` | Editor border in bash mode (`!` prefix) | +| `planMode` | Editor border in plan mode; optional, defaults to `#6FB01C` | ### HTML Export (optional) diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 446e57273..320009b65 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -440,7 +440,7 @@ renderResult(result, options, theme, context) { | Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` | | Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` | | Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax` | -| Modes | `bashMode` | +| Modes | `bashMode`, `planMode` | **Background colors** (`theme.bg(color, text)`): diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index bc310fed7..47d0096d2 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -10,7 +10,7 @@ The interface has four main areas: - **Startup header** - shortcuts, loaded context files, prompt templates, skills, and extensions - **Messages** - user messages, assistant responses, tool calls, tool results, notifications, errors, and extension UI -- **Editor** - where you type; border color indicates the current thinking level +- **Editor** - where you type; border color indicates plan mode or shell mode - **Footer** - the built-in footer keeps the current model, thinking level, and context usage visible. The editor can be replaced temporarily by built-in UI such as `/settings` or by custom extension UI. @@ -38,10 +38,10 @@ Type `/` in the editor to open command completion. Extensions can register custo |---------|-------------| | `/login`, `/logout` | Manage OAuth or API-key credentials | | [`/llama`](llama-cpp.md) | Download, load, and unload llama.cpp router models | -| `/model` | Switch models | +| `/model` | Switch models, then select thinking effort | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | -| `/plan [on\|off]` | Toggle plan mode, set it explicitly, or reopen a pending plan | -| `/settings` | Thinking level, theme, message delivery, transport | +| `/plan [instructions]` | Activate plan mode and optionally submit planning instructions, or reopen/retry a pending plan | +| `/settings` | Theme, message delivery, transport | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | | `/name ` | Set session display name | @@ -88,8 +88,9 @@ JSON and RPC modes emit the same notices as structured session events. ## Plan Mode -Start an interactive planning session with `diffex --plan`, toggle it with `/plan`, or set it explicitly with `/plan on` or `/plan off`. -Plan mode can be changed only while the session is idle. +Start an interactive planning session with `diffex --plan`, submit `/plan [instructions]`, or press Shift+Tab and type instructions in the editor. +A bare `/plan` activates planning without submitting a prompt. Shift+Tab toggles plan mode while the session is idle. +The editor border changes to `#6FB01C` while plan mode is active. Plan mode exposes `submit_plan` and canonical `read`, `grep`, `find`, and `ls` repository inspection tools when caller restrictions permit them. Extension tools, SDK custom tools, shell execution, and file mutation tools are unavailable until the plan is accepted or discarded. diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 0ae071145..5ef5e37da 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -372,7 +372,8 @@ export class Theme { private mode: ColorMode; constructor( - fgColors: Record, + fgColors: Record, string | number> & + Partial>, bgColors: Record, string | number> & Partial>, mode: ColorMode, @@ -383,7 +384,11 @@ export class Theme { this.sourceInfo = options.sourceInfo; this.mode = mode; this.fgColors = new Map(); - const colors = { ...fgColors, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh }; + const colors = { + ...fgColors, + thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, + planMode: fgColors.planMode ?? "#6FB01C", + }; for (const [key, value] of Object.entries(colors) as [ThemeColor, string | number][]) { this.fgColors.set(key, fgAnsi(value, mode)); } From 6daa7b7b5d0374085363b1aa7094d02d58a6576f Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Mon, 17 Aug 2026 10:27:58 -0700 Subject: [PATCH 5/8] fix(coding-agent): preserve plan command history --- .../src/modes/interactive/interactive-mode.ts | 9 ++++---- .../interactive-mode-plan-resolution.test.ts | 23 +++++++++++++++++++ .../test/interactive-mode-plan-test-utils.ts | 1 + 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0578c71cf..6f8f9b244 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3203,6 +3203,7 @@ export class InteractiveMode { this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); if (!text) return; + const historyEntry = text; if (text.startsWith("!")) { const isExcluded = text.startsWith("!!"); @@ -3213,7 +3214,7 @@ export class InteractiveMode { this.editor.setText(text); return; } - this.editor.addToHistory?.(text); + this.editor.addToHistory?.(historyEntry); await this.handleBashCommand(command, isExcluded); this.isBashMode = false; this.updateEditorBorderColor(); @@ -3365,7 +3366,7 @@ export class InteractiveMode { // Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { if (this.isExtensionCommand(text)) { - this.editor.addToHistory?.(text); + this.editor.addToHistory?.(historyEntry); this.editor.setText(""); await this.session.prompt(text); } else { @@ -3377,7 +3378,7 @@ export class InteractiveMode { // If streaming, use prompt() with steer behavior // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { - this.editor.addToHistory?.(text); + this.editor.addToHistory?.(historyEntry); this.editor.setText(""); try { await this.promptUser(text, { streamingBehavior: "steer" }); @@ -3398,7 +3399,7 @@ export class InteractiveMode { } else { this.pendingUserInputs.push(text); } - this.editor.addToHistory?.(text); + this.editor.addToHistory?.(historyEntry); }; } diff --git a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts index 7c67e7aab..187a74e83 100644 --- a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts @@ -310,6 +310,28 @@ describe("InteractiveMode plan resolution", () => { expect(context.showPendingPlanDecision).not.toHaveBeenCalled(); }); + test("keeps the /plan command in history while dispatching only its instructions", async () => { + const context: StreamingSubmitContext = { + defaultEditor: {}, + editor: { addToHistory: vi.fn(), setText: vi.fn() }, + session: { isCompacting: false, isStreaming: true }, + handlePlanCommand: vi.fn().mockResolvedValue("inspect the authentication flow"), + promptUser: vi.fn().mockResolvedValue(undefined), + updatePendingMessagesDisplay: vi.fn(), + openPendingPlanDecision: vi.fn(), + ui: { requestRender: vi.fn() }, + }; + setupEditorSubmitHandler.call(context); + if (!context.defaultEditor.onSubmit) throw new Error("Submit handler was not installed"); + + await context.defaultEditor.onSubmit("/plan inspect the authentication flow"); + + expect(context.editor.addToHistory).toHaveBeenCalledWith("/plan inspect the authentication flow"); + expect(context.promptUser).toHaveBeenCalledWith("inspect the authentication flow", { + streamingBehavior: "steer", + }); + }); + test("retries pending-plan admission after the last streaming submission preflight settles", async () => { const session = { isCompacting: false, @@ -329,6 +351,7 @@ describe("InteractiveMode plan resolution", () => { defaultEditor: {}, editor: { addToHistory: vi.fn(), setText: vi.fn() }, session, + handlePlanCommand: vi.fn(), promptUser: vi.fn( () => new Promise((resolve) => { diff --git a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts index 9463adeb3..aa831a891 100644 --- a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts +++ b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts @@ -158,6 +158,7 @@ export interface StreamingSubmitContext { isCompacting: boolean; isStreaming: boolean; }; + handlePlanCommand: ReturnType; promptUser: ReturnType; updatePendingMessagesDisplay: ReturnType; openPendingPlanDecision: ReturnType; From b0f6dafa38a2d760acbdceee3fb7f92553be90c0 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Mon, 17 Aug 2026 10:28:43 -0700 Subject: [PATCH 6/8] fix(coding-agent): synchronize plan editor borders --- .../src/modes/interactive/interactive-mode.ts | 14 +++++++------- .../test/interactive-mode-plan-editor.test.ts | 8 +++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 6f8f9b244..c4c2ff13d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4382,13 +4382,13 @@ export class InteractiveMode { } private updateEditorBorderColor(): void { - if (this.isBashMode) { - this.editor.borderColor = theme.getBashModeBorderColor(); - } else if (this.session.collaborationMode === "plan") { - this.editor.borderColor = theme.getPlanModeBorderColor(); - } else { - this.editor.borderColor = getEditorTheme().borderColor; - } + const borderColor = this.isBashMode + ? theme.getBashModeBorderColor() + : this.session.collaborationMode === "plan" + ? theme.getPlanModeBorderColor() + : getEditorTheme().borderColor; + this.defaultEditor.borderColor = borderColor; + this.editor.borderColor = borderColor; this.ui.requestRender(); } diff --git a/packages/coding-agent/test/interactive-mode-plan-editor.test.ts b/packages/coding-agent/test/interactive-mode-plan-editor.test.ts index 91425e11b..b2396825a 100644 --- a/packages/coding-agent/test/interactive-mode-plan-editor.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-editor.test.ts @@ -5,6 +5,7 @@ import { getEditorTheme, initTheme, theme } from "../src/modes/interactive/theme type EditorBorderContext = { isBashMode: boolean; session: { collaborationMode: "default" | "plan" }; + defaultEditor: { borderColor?: (text: string) => string }; editor: { borderColor?: (text: string) => string }; ui: { requestRender: ReturnType }; }; @@ -20,6 +21,7 @@ describe("plan mode editor border", () => { const context: EditorBorderContext = { isBashMode: false, session: { collaborationMode: "plan" }, + defaultEditor: {}, editor: {}, ui: { requestRender: vi.fn() }, }; @@ -27,22 +29,26 @@ describe("plan mode editor border", () => { updateEditorBorderColor.call(context); expect(context.editor.borderColor?.("line")).toBe(theme.getPlanModeBorderColor()("line")); + expect(context.defaultEditor.borderColor?.("line")).toBe(theme.getPlanModeBorderColor()("line")); }); - test("keeps bash mode ahead of plan mode and restores the normal border", () => { + test("keeps the default editor synchronized while a custom editor is active", () => { const context: EditorBorderContext = { isBashMode: true, session: { collaborationMode: "plan" }, + defaultEditor: {}, editor: {}, ui: { requestRender: vi.fn() }, }; updateEditorBorderColor.call(context); expect(context.editor.borderColor?.("line")).toBe(theme.getBashModeBorderColor()("line")); + expect(context.defaultEditor.borderColor?.("line")).toBe(theme.getBashModeBorderColor()("line")); context.isBashMode = false; context.session.collaborationMode = "default"; updateEditorBorderColor.call(context); expect(context.editor.borderColor?.("line")).toBe(getEditorTheme().borderColor("line")); + expect(context.defaultEditor.borderColor?.("line")).toBe(getEditorTheme().borderColor("line")); }); }); From 66e381c3b9c4d2aa721a5148f29016aa0196cbde Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Mon, 17 Aug 2026 10:31:53 -0700 Subject: [PATCH 7/8] fix(coding-agent): use green plan mode styling --- packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/themes.md | 9 ++- packages/coding-agent/docs/usage.md | 2 +- .../src/modes/interactive/interactive-mode.ts | 11 ++- .../src/modes/interactive/theme/dark.json | 2 +- .../src/modes/interactive/theme/light.json | 2 +- .../modes/interactive/theme/theme-schema.json | 2 +- .../src/modes/interactive/theme/theme.ts | 4 +- .../test/interactive-mode-plan-editor.test.ts | 81 ++++++++++++++++++- .../coding-agent/test/theme-detection.test.ts | 6 +- 10 files changed, 103 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 50d77c2a3..a17e3cf46 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -155,7 +155,7 @@ The editor can be temporarily replaced by other UI, like built-in `/settings` or ### Plan Mode Run `diffex --plan` to start an interactive session in plan mode, submit `/plan [instructions]`, or press Shift+Tab and type instructions in the editor. -Shift+Tab toggles plan mode while the editor border uses `#6FB01C` to show that planning is active. +Shift+Tab toggles plan mode while the editor border and prompt pointer turn green to show that planning is active. Plan mode exposes `submit_plan` and the canonical subset of `read`, `grep`, `find`, and `ls` permitted by caller restrictions. The agent can inspect the repository, but it cannot run commands, edit files, or call extension and SDK tools. `submit_plan` is intrinsic to plan mode and remains available even when caller tool filters disable every inspection tool. diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index b4538f839..3d64bc92f 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -56,7 +56,8 @@ vim ~/.diffex/agent/themes/my-theme.json "name": "my-theme", "vars": { "primary": "#00aaff", - "secondary": 242 + "secondary": 242, + "green": "#00ff00" }, "colors": { "accent": "primary", @@ -115,7 +116,7 @@ vim ~/.diffex/agent/themes/my-theme.json "thinkingXhigh": "#ff0000", "thinkingMax": "#ff0088", "bashMode": "#ffaa00", - "planMode": "#6FB01C" + "planMode": "green" } } ``` @@ -154,7 +155,7 @@ The `$schema` field enables editor auto-completion and validation. Every theme must define all 51 required color tokens. `planMode`, `thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, `selectionAccent`, and `autocompleteSelected` are optional for compatibility with existing themes. -When omitted, `planMode` defaults to `#6FB01C`; the remaining tokens use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, `autocompleteSelected` or `startupIntroAccent`, and `selectionAccent`, respectively. +When omitted, `planMode` uses `toolDiffAdded`; the remaining tokens use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, `autocompleteSelected` or `startupIntroAccent`, and `selectionAccent`, respectively. ### Core UI (11 colors) @@ -254,7 +255,7 @@ Legacy thinking-level colors exposed through the theme API for extensions. The b | Token | Purpose | |-------|---------| | `bashMode` | Editor border in bash mode (`!` prefix) | -| `planMode` | Editor border in plan mode; optional, defaults to `#6FB01C` | +| `planMode` | Editor border and prompt pointer in plan mode; optional, falls back to `toolDiffAdded` | ### HTML Export (optional) diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 47d0096d2..4144367ee 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -90,7 +90,7 @@ JSON and RPC modes emit the same notices as structured session events. Start an interactive planning session with `diffex --plan`, submit `/plan [instructions]`, or press Shift+Tab and type instructions in the editor. A bare `/plan` activates planning without submitting a prompt. Shift+Tab toggles plan mode while the session is idle. -The editor border changes to `#6FB01C` while plan mode is active. +The editor border and prompt pointer change to the theme's plan-mode green while plan mode is active. Plan mode exposes `submit_plan` and canonical `read`, `grep`, `find`, and `ls` repository inspection tools when caller restrictions permit them. Extension tools, SDK custom tools, shell execution, and file mutation tools are unavailable until the plan is accepted or discarded. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c4c2ff13d..f15995ca1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -610,8 +610,7 @@ export class InteractiveMode { paddingX: editorPaddingX, autocompleteMaxVisible, prompt: SELECTED_LIST_PREFIX, - promptStyle: (text) => - this.isBashMode ? theme.fg("bashMode", text.replace(/^>/, "!")) : theme.fg("selectionAccent", text), + promptStyle: (text) => this.styleEditorPrompt(text), concealedInputPrefix: "!", placeholder: "Ask Diffex to do something for you.", placeholderStyle: (text) => theme.fg("muted", text), @@ -2970,8 +2969,8 @@ export class InteractiveMode { if (!preserveExclusiveOwner) { this.editorContainer.addChild(this.editor as Component); this.ui.setFocus(this.editor as Component); - this.ui.requestRender(); } + this.updateEditorBorderColor(); } /** @@ -4381,6 +4380,12 @@ export class InteractiveMode { } } + private styleEditorPrompt(text: string): string { + if (this.isBashMode) return theme.fg("bashMode", text.replace(/^>/, "!")); + if (this.session.collaborationMode === "plan") return theme.fg("planMode", text); + return theme.fg("selectionAccent", text); + } + private updateEditorBorderColor(): void { const borderColor = this.isBashMode ? theme.getBashModeBorderColor() diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 9c54d11d0..eca6e42c3 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -85,7 +85,7 @@ "thinkingMax": "#ff5fff", "bashMode": "red", - "planMode": "#6FB01C" + "planMode": "green" }, "export": { "pageBg": "#18181e", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index b82bbfbb4..2f5cc4dde 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -84,7 +84,7 @@ "thinkingMax": "#af005f", "bashMode": "red", - "planMode": "#6FB01C" + "planMode": "green" }, "export": { "pageBg": "#f8f8f8", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 0d34c074a..835c359a6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -323,7 +323,7 @@ }, "planMode": { "$ref": "#/$defs/colorValue", - "description": "Editor border color in plan mode (optional, defaults to #6FB01C)" + "description": "Editor border and prompt color in plan mode (optional, falls back to toolDiffAdded)" } }, "additionalProperties": false diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 5ef5e37da..90e9c58fa 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -355,7 +355,7 @@ function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors selectionAccent, autocompleteSelected: colors.autocompleteSelected ?? selectionAccent, editorBorder: colors.editorBorder ?? colors.borderMuted, - planMode: colors.planMode ?? "#6FB01C", + planMode: colors.planMode ?? colors.toolDiffAdded, }; } @@ -387,7 +387,7 @@ export class Theme { const colors = { ...fgColors, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, - planMode: fgColors.planMode ?? "#6FB01C", + planMode: fgColors.planMode ?? fgColors.toolDiffAdded, }; for (const [key, value] of Object.entries(colors) as [ThemeColor, string | number][]) { this.fgColors.set(key, fgAnsi(value, mode)); diff --git a/packages/coding-agent/test/interactive-mode-plan-editor.test.ts b/packages/coding-agent/test/interactive-mode-plan-editor.test.ts index b2396825a..fb34653f8 100644 --- a/packages/coding-agent/test/interactive-mode-plan-editor.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-editor.test.ts @@ -1,7 +1,33 @@ +import { type Component, Container } from "@earendil-works/diffex-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; import { getEditorTheme, initTheme, theme } from "../src/modes/interactive/theme/theme.ts"; +class TestEditor implements Component { + borderColor?: (text: string) => string; + onSubmit?: (text: string) => void; + onChange?: (text: string) => void; + private text: string; + + constructor(text = "") { + this.text = text; + } + + getText(): string { + return this.text; + } + + setText(text: string): void { + this.text = text; + } + + render(): string[] { + return [this.text]; + } + + invalidate(): void {} +} + type EditorBorderContext = { isBashMode: boolean; session: { collaborationMode: "default" | "plan" }; @@ -10,13 +36,46 @@ type EditorBorderContext = { ui: { requestRender: ReturnType }; }; +type EditorReplacementContext = Omit & { + activeEditorUi?: { exclusive?: boolean }; + cancelActiveEditorUi: ReturnType; + defaultEditor: TestEditor; + editor: TestEditor; + editorComponentFactory?: undefined; + editorContainer: Container; + ui: { + requestRender: ReturnType; + setFocus: ReturnType; + }; + updateEditorBorderColor: () => void; +}; + +const styleEditorPrompt = Reflect.get(InteractiveMode.prototype, "styleEditorPrompt") as ( + this: Pick, + text: string, +) => string; + const updateEditorBorderColor = Reflect.get(InteractiveMode.prototype, "updateEditorBorderColor") as ( this: EditorBorderContext, ) => void; +const setCustomEditorComponent = Reflect.get(InteractiveMode.prototype, "setCustomEditorComponent") as ( + this: EditorReplacementContext, + factory: undefined, +) => void; + beforeAll(() => initTheme("dark")); -describe("plan mode editor border", () => { +describe("plan mode editor appearance", () => { + test("uses plan green for the prompt pointer", () => { + const context = { + isBashMode: false, + session: { collaborationMode: "plan" as const }, + }; + + expect(styleEditorPrompt.call(context, "> ")).toBe(theme.fg("planMode", "> ")); + }); + test("uses plan green while plan mode is active", () => { const context: EditorBorderContext = { isBashMode: false, @@ -32,6 +91,26 @@ describe("plan mode editor border", () => { expect(context.defaultEditor.borderColor?.("line")).toBe(theme.getPlanModeBorderColor()("line")); }); + test("recomputes the plan border when restoring the default editor", () => { + const defaultEditor = new TestEditor(); + const context: EditorReplacementContext = { + isBashMode: false, + session: { collaborationMode: "plan" }, + cancelActiveEditorUi: vi.fn(), + defaultEditor, + editor: new TestEditor("draft"), + editorContainer: new Container(), + ui: { requestRender: vi.fn(), setFocus: vi.fn() }, + updateEditorBorderColor: () => updateEditorBorderColor.call(context), + }; + + setCustomEditorComponent.call(context, undefined); + + expect(context.editor).toBe(defaultEditor); + expect(defaultEditor.getText()).toBe("draft"); + expect(defaultEditor.borderColor?.("line")).toBe(theme.getPlanModeBorderColor()("line")); + }); + test("keeps the default editor synchronized while a custom editor is active", () => { const context: EditorBorderContext = { isBashMode: true, diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index ed3f3af5f..73c52984f 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -254,7 +254,7 @@ describe("editor border theme", () => { expect(getEditorTheme().borderColor("line")).toBe(`${expected}line\x1b[39m`); expect(theme.getThinkingBorderColor("low")("line")).not.toBe(`${expected}line\x1b[39m`); expect(theme.getBashModeBorderColor()("line")).toBe("\x1b[38;2;204;102;102mline\x1b[39m"); - expect(theme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;111;176;28mline\x1b[39m"); + expect(theme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;88;161;112mline\x1b[39m"); }); it("uses the fixed light theme color", () => { @@ -263,7 +263,7 @@ describe("editor border theme", () => { expect(theme.getFgAnsi("editorBorder")).toBe("\x1b[38;2;60;89;122m"); expect(theme.getBashModeBorderColor()("line")).toBe("\x1b[38;2;170;85;85mline\x1b[39m"); - expect(theme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;111;176;28mline\x1b[39m"); + expect(theme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;88;132;88mline\x1b[39m"); }); it("falls back to borderMuted for existing custom themes", () => { @@ -281,6 +281,6 @@ describe("editor border theme", () => { const legacyTheme = loadThemeFromPath(themePath, "truecolor"); expect(legacyTheme.getFgAnsi("editorBorder")).toBe(legacyTheme.getFgAnsi("borderMuted")); - expect(legacyTheme.getPlanModeBorderColor()("line")).toBe("\x1b[38;2;111;176;28mline\x1b[39m"); + expect(legacyTheme.getFgAnsi("planMode")).toBe(legacyTheme.getFgAnsi("toolDiffAdded")); }); }); From eb8ecfcc156f52cbca073fc73d09dcd0aec77abe Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Mon, 17 Aug 2026 10:33:01 -0700 Subject: [PATCH 8/8] fix(coding-agent): preserve plan history during compaction --- .../src/modes/interactive/interactive-mode.ts | 6 +-- .../interactive-mode-plan-resolution.test.ts | 45 ++++++++++++++++--- .../test/interactive-mode-plan-test-utils.ts | 5 +++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f15995ca1..39419c2ea 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3369,7 +3369,7 @@ export class InteractiveMode { this.editor.setText(""); await this.session.prompt(text); } else { - this.queueCompactionMessage(text, "steer"); + this.queueCompactionMessage(text, "steer", historyEntry); } return; } @@ -4624,9 +4624,9 @@ export class InteractiveMode { return allQueued.length; } - private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { + private queueCompactionMessage(text: string, mode: "steer" | "followUp", historyEntry = text): void { this.compactionQueuedMessages.push({ text, mode }); - this.editor.addToHistory?.(text); + this.editor.addToHistory?.(historyEntry); this.editor.setText(""); this.updatePendingMessagesDisplay(); this.showStatus("Queued message for after compaction"); diff --git a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts index 187a74e83..fa0bf8c8a 100644 --- a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts @@ -311,12 +311,18 @@ describe("InteractiveMode plan resolution", () => { }); test("keeps the /plan command in history while dispatching only its instructions", async () => { + const onInputCallback = vi.fn(); const context: StreamingSubmitContext = { defaultEditor: {}, editor: { addToHistory: vi.fn(), setText: vi.fn() }, - session: { isCompacting: false, isStreaming: true }, + session: { isCompacting: false, isStreaming: false }, + flushPendingBashComponents: vi.fn(), handlePlanCommand: vi.fn().mockResolvedValue("inspect the authentication flow"), - promptUser: vi.fn().mockResolvedValue(undefined), + isExtensionCommand: vi.fn(), + onInputCallback, + pendingUserInputs: [], + promptUser: vi.fn(), + queueCompactionMessage: vi.fn(), updatePendingMessagesDisplay: vi.fn(), openPendingPlanDecision: vi.fn(), ui: { requestRender: vi.fn() }, @@ -327,9 +333,34 @@ describe("InteractiveMode plan resolution", () => { await context.defaultEditor.onSubmit("/plan inspect the authentication flow"); expect(context.editor.addToHistory).toHaveBeenCalledWith("/plan inspect the authentication flow"); - expect(context.promptUser).toHaveBeenCalledWith("inspect the authentication flow", { - streamingBehavior: "steer", - }); + expect(onInputCallback).toHaveBeenCalledWith("inspect the authentication flow"); + }); + + test("preserves /plan history while compaction queues transformed instructions", async () => { + const context: StreamingSubmitContext = { + defaultEditor: {}, + editor: { addToHistory: vi.fn(), setText: vi.fn() }, + session: { isCompacting: true, isStreaming: false }, + flushPendingBashComponents: vi.fn(), + handlePlanCommand: vi.fn().mockResolvedValue("inspect the authentication flow"), + isExtensionCommand: vi.fn().mockReturnValue(false), + pendingUserInputs: [], + promptUser: vi.fn(), + queueCompactionMessage: vi.fn(), + updatePendingMessagesDisplay: vi.fn(), + openPendingPlanDecision: vi.fn(), + ui: { requestRender: vi.fn() }, + }; + setupEditorSubmitHandler.call(context); + if (!context.defaultEditor.onSubmit) throw new Error("Submit handler was not installed"); + + await context.defaultEditor.onSubmit("/plan inspect the authentication flow"); + + expect(context.queueCompactionMessage).toHaveBeenCalledWith( + "inspect the authentication flow", + "steer", + "/plan inspect the authentication flow", + ); }); test("retries pending-plan admission after the last streaming submission preflight settles", async () => { @@ -351,13 +382,17 @@ describe("InteractiveMode plan resolution", () => { defaultEditor: {}, editor: { addToHistory: vi.fn(), setText: vi.fn() }, session, + flushPendingBashComponents: vi.fn(), handlePlanCommand: vi.fn(), + isExtensionCommand: vi.fn(), + pendingUserInputs: [], promptUser: vi.fn( () => new Promise((resolve) => { releases.push(resolve); }), ), + queueCompactionMessage: vi.fn(), updatePendingMessagesDisplay: vi.fn(), openPendingPlanDecision: vi.fn(() => openPendingPlanDecision.call(openContext)), ui: { requestRender: vi.fn() }, diff --git a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts index aa831a891..021b2d590 100644 --- a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts +++ b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts @@ -158,8 +158,13 @@ export interface StreamingSubmitContext { isCompacting: boolean; isStreaming: boolean; }; + flushPendingBashComponents: ReturnType; handlePlanCommand: ReturnType; + isExtensionCommand: ReturnType; + onInputCallback?: ReturnType; + pendingUserInputs: string[]; promptUser: ReturnType; + queueCompactionMessage: ReturnType; updatePendingMessagesDisplay: ReturnType; openPendingPlanDecision: ReturnType; ui: { requestRender: ReturnType };