diff --git a/docs/users/features/approval-mode.md b/docs/users/features/approval-mode.md index c46067093d..60fb0d97cb 100644 --- a/docs/users/features/approval-mode.md +++ b/docs/users/features/approval-mode.md @@ -1,6 +1,6 @@ # Approval Mode -Qwen Code offers three distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level. +Qwen Code offers four distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level. ## Permission Modes Comparison @@ -40,6 +40,18 @@ You can switch into Plan Mode during a session using **Shift+Tab** (or **Tab** o If you are in Normal Mode, **Shift+Tab** (or **Tab** on Windows) first switches into `auto-edits` Mode, indicated by `⏵⏵ accept edits on` at the bottom of the terminal. A subsequent **Shift+Tab** (or **Tab** on Windows) will switch into Plan Mode, indicated by `⏸ plan mode`. +**Use the `/plan` command** + +The `/plan` command provides a quick shortcut for entering and exiting Plan Mode: + +```bash +/plan # Enter plan mode +/plan refactor the auth module # Enter plan mode and start planning +/plan exit # Exit plan mode, restore previous mode +``` + +When you exit Plan Mode with `/plan exit`, your previous approval mode is automatically restored (e.g., if you were in Auto-Edit before entering Plan Mode, you'll return to Auto-Edit). + **Start a new session in Plan Mode** To start a new session in Plan Mode, use the `/approval-mode` then select `plan` @@ -59,14 +71,10 @@ qwen --prompt "What is machine learning?" ### Example: Planning a complex refactor ```bash -/approval-mode plan -``` - -``` -I need to refactor our authentication system to use OAuth2. Create a detailed migration plan. +/plan I need to refactor our authentication system to use OAuth2. Create a detailed migration plan. ``` -Qwen Code analyzes the current implementation and create a comprehensive plan. Refine with follow-ups: +Qwen Code enters Plan Mode and analyzes the current implementation to create a comprehensive plan. Refine with follow-ups: ``` What about backward compatibility? @@ -235,7 +243,7 @@ qwen --prompt "Run the test suite, fix all failing tests, then commit changes" ### Keyboard Shortcut Switching -During a Qwen Code session, use **Shift+Tab**​ (or **Tab** on Windows) to quickly cycle through the three modes: +During a Qwen Code session, use **Shift+Tab**​ (or **Tab** on Windows) to quickly cycle through the four modes: ``` Default Mode → Auto-Edit Mode → YOLO Mode → Plan Mode → Default Mode diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index d99e3a6473..03587e15ae 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -61,6 +61,7 @@ Commands for managing AI tools and models. | `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` | | `/tools` | Display currently available tool list | `/tools`, `/tools desc` | | `/skills` | List and run available skills | `/skills`, `/skills ` | +| `/plan` | Switch to plan mode or exit plan mode | `/plan`, `/plan `, `/plan exit` | | `/approval-mode` | Change approval mode for tool usage | `/approval-mode --project` | | →`plan` | Analysis only, no execution | Secure review | | →`default` | Require approval for edits | Daily use | diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 2208c8d8be..6f028f9574 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1973,4 +1973,15 @@ export default { 'Vollständige Tool-Ausgabe und Denkprozess im ausführlichen Modus anzeigen (mit Strg+O umschalten).', 'Press Ctrl+O to show full tool output': 'Strg+O für vollständige Tool-Ausgabe drücken', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 4e7c2409aa..065bb8b8f4 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2013,4 +2013,15 @@ export default { 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).', 'Press Ctrl+O to show full tool output': 'Press Ctrl+O to show full tool output', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 3781b39e04..df79083c00 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1464,4 +1464,15 @@ export default { 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': '詳細モードで完全なツール出力と思考を表示します(Ctrl+O で切り替え)。', 'Press Ctrl+O to show full tool output': 'Ctrl+O で完全なツール出力を表示', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 5d1901f2d9..6c67fedb76 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1963,4 +1963,15 @@ export default { 'Mostrar saída completa da ferramenta e raciocínio no modo detalhado (alternar com Ctrl+O).', 'Press Ctrl+O to show full tool output': 'Pressione Ctrl+O para exibir a saída completa da ferramenta', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 9ea41ec03d..3ccb15b674 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1970,4 +1970,15 @@ export default { 'Показывать полный вывод инструментов и процесс рассуждений в подробном режиме (переключить с помощью Ctrl+O).', 'Press Ctrl+O to show full tool output': 'Нажмите Ctrl+O для показа полного вывода инструментов', + + 'Switch to plan mode or exit plan mode': + 'Switch to plan mode or exit plan mode', + 'Exited plan mode. Previous approval mode restored.': + 'Exited plan mode. Previous approval mode restored.', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + 'Already in plan mode. Use "/plan exit" to exit plan mode.', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + 'Not in plan mode. Use "/plan" to enter plan mode first.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index c232424370..3ac427a088 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1817,4 +1817,14 @@ export default { 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': '详细模式下显示完整工具输出和思考过程(Ctrl+O 切换)。', 'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果', + + 'Switch to plan mode or exit plan mode': '切换到计划模式或退出计划模式', + 'Exited plan mode. Previous approval mode restored.': + '已退出计划模式,已恢复之前的审批模式。', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + '启用计划模式。智能体将只分析和规划,而不执行工具。', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + '已处于计划模式。使用 "/plan exit" 退出计划模式。', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + '未处于计划模式。请先使用 "/plan" 进入计划模式。', }; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 5db6c965a0..20e9b13c62 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -32,6 +32,7 @@ import { languageCommand } from '../ui/commands/languageCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; +import { planCommand } from '../ui/commands/planCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; @@ -103,6 +104,7 @@ export class BuiltinCommandLoader implements ICommandLoader { mcpCommand, memoryCommand, modelCommand, + planCommand, permissionsCommand, ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts new file mode 100644 index 0000000000..f23b6afb45 --- /dev/null +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { planCommand } from './planCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; + +describe('planCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext({ + services: { + config: { + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getPrePlanMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + setApprovalMode: vi.fn(), + } as unknown as import('@qwen-code/qwen-code-core').Config, + }, + }); + }); + + it('should switch to plan mode if not in plan mode', async () => { + if (!planCommand.action) { + throw new Error('The plan command must have an action.'); + } + + const result = await planCommand.action(mockContext, ''); + + expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.PLAN, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + }); + }); + + it('should return submit prompt if arguments are provided when switching to plan mode', async () => { + if (!planCommand.action) { + throw new Error('The plan command must have an action.'); + } + + const result = await planCommand.action(mockContext, 'refactor the code'); + + expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.PLAN, + ); + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'refactor the code' }], + }); + }); + + it('should return already in plan mode if mode is already plan', async () => { + if (!planCommand.action) { + throw new Error('The plan command must have an action.'); + } + + (mockContext.services.config?.getApprovalMode as Mock).mockReturnValue( + ApprovalMode.PLAN, + ); + + const result = await planCommand.action(mockContext, ''); + + expect(mockContext.services.config?.setApprovalMode).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Already in plan mode. Use "/plan exit" to exit plan mode.', + }); + }); + + it('should return submit prompt if arguments are provided and already in plan mode', async () => { + if (!planCommand.action) { + throw new Error('The plan command must have an action.'); + } + + (mockContext.services.config?.getApprovalMode as Mock).mockReturnValue( + ApprovalMode.PLAN, + ); + + const result = await planCommand.action(mockContext, 'keep planning'); + + expect(mockContext.services.config?.setApprovalMode).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'keep planning' }], + }); + }); + + it('should exit plan mode when exit argument is passed', async () => { + if (!planCommand.action) { + throw new Error('The plan command must have an action.'); + } + + (mockContext.services.config?.getApprovalMode as Mock).mockReturnValue( + ApprovalMode.PLAN, + ); + + const result = await planCommand.action(mockContext, 'exit'); + + expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.DEFAULT, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Exited plan mode. Previous approval mode restored.', + }); + }); + + it('should restore pre-plan mode when executing from plan mode', async () => { + if (!planCommand.action) { + throw new Error('The plan command must have an action.'); + } + + (mockContext.services.config?.getApprovalMode as Mock).mockReturnValue( + ApprovalMode.PLAN, + ); + (mockContext.services.config?.getPrePlanMode as Mock).mockReturnValue( + ApprovalMode.AUTO_EDIT, + ); + + const result = await planCommand.action(mockContext, 'exit'); + + expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Exited plan mode. Previous approval mode restored.', + }); + }); + + it('should return error when execute is used but not in plan mode', async () => { + if (!planCommand.action) { + throw new Error('The plan command must have an action.'); + } + + // Default mock returns ApprovalMode.DEFAULT (not PLAN) + const result = await planCommand.action(mockContext, 'exit'); + + expect(mockContext.services.config?.setApprovalMode).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Not in plan mode. Use "/plan" to enter plan mode first.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts new file mode 100644 index 0000000000..494ee463fe --- /dev/null +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + CommandKind, + type SlashCommand, + type MessageActionReturn, + type SubmitPromptActionReturn, +} from './types.js'; +import { t } from '../../i18n/index.js'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; + +export const planCommand: SlashCommand = { + name: 'plan', + get description() { + return t('Switch to plan mode or exit plan mode'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Configuration is not available.'), + }; + } + + const trimmedArgs = args.trim(); + const currentMode = config.getApprovalMode(); + + if (trimmedArgs === 'exit') { + if (currentMode !== ApprovalMode.PLAN) { + return { + type: 'message', + messageType: 'error', + content: t('Not in plan mode. Use "/plan" to enter plan mode first.'), + }; + } + try { + config.setApprovalMode(config.getPrePlanMode()); + } catch (e) { + return { + type: 'message', + messageType: 'error', + content: (e as Error).message, + }; + } + return { + type: 'message', + messageType: 'info', + content: t('Exited plan mode. Previous approval mode restored.'), + }; + } + + if (currentMode !== ApprovalMode.PLAN) { + try { + config.setApprovalMode(ApprovalMode.PLAN); + } catch (e) { + return { + type: 'message', + messageType: 'error', + content: (e as Error).message, + }; + } + + if (trimmedArgs) { + return { + type: 'submit_prompt', + content: [{ text: trimmedArgs }], + }; + } + + return { + type: 'message', + messageType: 'info', + content: t( + 'Enabled plan mode. The agent will analyze and plan without executing tools.', + ), + }; + } + + // Already in plan mode + if (trimmedArgs) { + return { + type: 'submit_prompt', + content: [{ text: trimmedArgs }], + }; + } + + return { + type: 'message', + messageType: 'info', + content: t('Already in plan mode. Use "/plan exit" to exit plan mode.'), + }; + }, +}; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 17d88b1749..f828ff4ee3 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import type { ConfigParameters, SandboxConfig } from './config.js'; import { Config, ApprovalMode } from './config.js'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js'; import { @@ -57,6 +58,9 @@ vi.mock('node:fs', async (importOriginal) => { isDirectory: vi.fn().mockReturnValue(true), }), realpathSync: vi.fn((path) => path), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readFileSync: vi.fn(), }; return { ...mocked, @@ -1203,6 +1207,103 @@ describe('setApprovalMode with folder trust', () => { expect(() => config.setApprovalMode(ApprovalMode.PLAN)).not.toThrow(); }); + describe('prePlanMode tracking', () => { + it('should save pre-plan mode when entering plan mode', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + config.setApprovalMode(ApprovalMode.AUTO_EDIT); + config.setApprovalMode(ApprovalMode.PLAN); + expect(config.getPrePlanMode()).toBe(ApprovalMode.AUTO_EDIT); + }); + + it('should clear pre-plan mode when leaving plan mode', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + config.setApprovalMode(ApprovalMode.AUTO_EDIT); + config.setApprovalMode(ApprovalMode.PLAN); + config.setApprovalMode(ApprovalMode.DEFAULT); + expect(config.getPrePlanMode()).toBe(ApprovalMode.DEFAULT); + }); + + it('should default to DEFAULT when no pre-plan mode was recorded', () => { + const config = new Config(baseParams); + expect(config.getPrePlanMode()).toBe(ApprovalMode.DEFAULT); + }); + + it('should not update pre-plan mode when already in plan mode', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + config.setApprovalMode(ApprovalMode.YOLO); + config.setApprovalMode(ApprovalMode.PLAN); + // Setting PLAN again should not overwrite prePlanMode + config.setApprovalMode(ApprovalMode.PLAN); + expect(config.getPrePlanMode()).toBe(ApprovalMode.YOLO); + }); + }); + + describe('plan file persistence', () => { + it('should save plan to disk', () => { + const config = new Config(baseParams); + + config.savePlan('# My Plan\n1. Step one\n2. Step two'); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('plans'), + { recursive: true }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('.md'), + '# My Plan\n1. Step one\n2. Step two', + 'utf-8', + ); + }); + + it('should load plan from disk', () => { + const config = new Config(baseParams); + (fs.readFileSync as Mock).mockReturnValue('# Saved Plan'); + + const plan = config.loadPlan(); + expect(plan).toBe('# Saved Plan'); + }); + + it('should return undefined when no plan file exists', () => { + const config = new Config(baseParams); + const enoentError = new Error('ENOENT') as NodeJS.ErrnoException; + enoentError.code = 'ENOENT'; + (fs.readFileSync as Mock).mockImplementation(() => { + throw enoentError; + }); + + const plan = config.loadPlan(); + expect(plan).toBeUndefined(); + }); + + it('should rethrow non-ENOENT errors from loadPlan', () => { + const config = new Config(baseParams); + const permError = new Error('EACCES') as NodeJS.ErrnoException; + permError.code = 'EACCES'; + (fs.readFileSync as Mock).mockImplementation(() => { + throw permError; + }); + + expect(() => config.loadPlan()).toThrow('EACCES'); + }); + + it('should use session ID in plan file path', () => { + const config = new Config({ + ...baseParams, + sessionId: 'test-session-123', + }); + + const filePath = config.getPlanFilePath(); + expect(filePath).toContain('test-session-123'); + expect(filePath).toMatch(/\.md$/); + }); + }); + describe('registerCoreTools', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c54b557052..31d1c4c040 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -6,6 +6,7 @@ // Node built-ins import type { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import process from 'node:process'; @@ -529,6 +530,7 @@ export class Config { private sdkMode: boolean; private geminiMdFileCount: number; private approvalMode: ApprovalMode; + private prePlanMode?: ApprovalMode; private readonly accessibility: AccessibilitySettings; private readonly telemetrySettings: TelemetrySettings; private readonly gitCoAuthor: GitCoAuthorSettings; @@ -1634,6 +1636,14 @@ export class Config { return this.approvalMode; } + /** + * Returns the approval mode that was active before entering plan mode. + * Falls back to DEFAULT if no pre-plan mode was recorded. + */ + getPrePlanMode(): ApprovalMode { + return this.prePlanMode ?? ApprovalMode.DEFAULT; + } + setApprovalMode(mode: ApprovalMode): void { if ( !this.isTrustedFolder() && @@ -1644,9 +1654,55 @@ export class Config { 'Cannot enable privileged approval modes in an untrusted folder.', ); } + // Track the mode before entering plan mode so it can be restored later + if (mode === ApprovalMode.PLAN && this.approvalMode !== ApprovalMode.PLAN) { + this.prePlanMode = this.approvalMode; + } else if ( + mode !== ApprovalMode.PLAN && + this.approvalMode === ApprovalMode.PLAN + ) { + this.prePlanMode = undefined; + } this.approvalMode = mode; } + /** + * Returns the file path for this session's plan file. + */ + getPlanFilePath(): string { + return Storage.getPlanFilePath(this.sessionId); + } + + /** + * Saves a plan to disk for the current session. + */ + savePlan(plan: string): void { + const filePath = this.getPlanFilePath(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, plan, 'utf-8'); + } + + /** + * Loads the plan for the current session, or returns undefined if none exists. + */ + loadPlan(): string | undefined { + const filePath = this.getPlanFilePath(); + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return undefined; + } + throw error; + } + } + getInputFormat(): 'text' | 'stream-json' { return this.inputFormat; } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index e29cefa621..d14998dec5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -18,6 +18,7 @@ const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; const IDE_DIR_NAME = 'ide'; +const PLANS_DIR_NAME = 'plans'; const DEBUG_DIR_NAME = 'debug'; const ARENA_DIR_NAME = 'arena'; @@ -165,6 +166,14 @@ export class Storage { return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME); } + static getPlansDir(): string { + return path.join(Storage.getGlobalQwenDir(), PLANS_DIR_NAME); + } + + static getPlanFilePath(sessionId: string): string { + return path.join(Storage.getPlansDir(), `${sessionId}.md`); + } + static getGlobalBinDir(): string { return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); } diff --git a/packages/core/src/tools/exitPlanMode.test.ts b/packages/core/src/tools/exitPlanMode.test.ts index 81a1dbad63..f1d80430bf 100644 --- a/packages/core/src/tools/exitPlanMode.test.ts +++ b/packages/core/src/tools/exitPlanMode.test.ts @@ -18,9 +18,11 @@ describe('ExitPlanModeTool', () => { approvalMode = ApprovalMode.PLAN; mockConfig = { getApprovalMode: vi.fn(() => approvalMode), + getPrePlanMode: vi.fn(() => ApprovalMode.DEFAULT), setApprovalMode: vi.fn((mode: ApprovalMode) => { approvalMode = mode; }), + savePlan: vi.fn(), } as unknown as Config; tool = new ExitPlanModeTool(mockConfig); @@ -147,6 +149,9 @@ describe('ExitPlanModeTool', () => { ApprovalMode.DEFAULT, ); expect(approvalMode).toBe(ApprovalMode.DEFAULT); + + // Plan should be saved to disk + expect(mockConfig.savePlan).toHaveBeenCalledWith(params.plan); }); it('should request confirmation with plan details', async () => { @@ -173,6 +178,29 @@ describe('ExitPlanModeTool', () => { expect(approvalMode).toBe(ApprovalMode.AUTO_EDIT); }); + it('should set DEFAULT mode on ProceedOnce regardless of pre-plan mode', async () => { + // Even if pre-plan mode was AUTO_EDIT, ProceedOnce ("manually approve + // edits") should always set DEFAULT to match the option label semantics. + (mockConfig.getPrePlanMode as ReturnType).mockReturnValue( + ApprovalMode.AUTO_EDIT, + ); + + const params: ExitPlanModeParams = { plan: 'Restore test' }; + const signal = new AbortController().signal; + + const invocation = tool.build(params); + const confirmation = await invocation.getConfirmationDetails(signal); + + if (confirmation) { + await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce); + } + + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.DEFAULT, + ); + expect(approvalMode).toBe(ApprovalMode.DEFAULT); + }); + it('should remain in plan mode when confirmation is rejected', async () => { const params: ExitPlanModeParams = { plan: 'Remain in planning', @@ -199,6 +227,9 @@ describe('ExitPlanModeTool', () => { ApprovalMode.PLAN, ); expect(approvalMode).toBe(ApprovalMode.PLAN); + + // Plan should NOT be saved when rejected + expect(mockConfig.savePlan).not.toHaveBeenCalled(); }); it('should have correct description', () => { diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index ca71a97753..03485e4cf0 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -147,6 +147,15 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation< }; } + // Persist the approved plan to disk + try { + this.config.savePlan(plan); + } catch (error) { + debugLogger.warn( + `[ExitPlanModeTool] Failed to save plan to disk: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const llmMessage = `User has approved your plan. You can now start coding. Start with updating your todo list if applicable.`; const displayMessage = 'User approved the plan.'; diff --git a/packages/webui/src/hooks/useFollowupSuggestions.ts b/packages/webui/src/hooks/useFollowupSuggestions.ts index ee5f010036..607990484b 100644 --- a/packages/webui/src/hooks/useFollowupSuggestions.ts +++ b/packages/webui/src/hooks/useFollowupSuggestions.ts @@ -112,7 +112,6 @@ function createFollowupController( suggestion_length: text.length, }); } catch (e: unknown) { - console.error('[followup] onOutcome callback threw:', e); } @@ -122,7 +121,6 @@ function createFollowupController( try { getOnAccept?.()?.(text); } catch (error: unknown) { - console.error('[followup] onAccept callback threw:', error); } finally { if (acceptTimeoutId) { @@ -154,7 +152,6 @@ function createFollowupController( suggestion_length: currentState.suggestion.length, }); } catch (e: unknown) { - console.error('[followup] onOutcome callback threw:', e); } }