diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9731fe707..1871e0f3f 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -697,6 +697,10 @@ export class Session implements SessionContext { case ToolConfirmationOutcome.ProceedAlways: newModeId = 'auto-edit'; break; + case ToolConfirmationOutcome.RestorePrevious: + // onConfirm has already restored the mode; read the actual current mode + newModeId = this.config.getApprovalMode() as ApprovalModeValue; + break; case ToolConfirmationOutcome.ProceedOnce: default: newModeId = 'default'; @@ -1045,6 +1049,7 @@ export class Session implements SessionContext { case ToolConfirmationOutcome.ProceedAlwaysServer: case ToolConfirmationOutcome.ProceedAlwaysTool: case ToolConfirmationOutcome.ModifyWithEditor: + case ToolConfirmationOutcome.RestorePrevious: break; default: { const resultOutcome: never = outcome; diff --git a/packages/cli/src/acp-integration/session/permissionUtils.test.ts b/packages/cli/src/acp-integration/session/permissionUtils.test.ts index 743049f2e..49a8936c0 100644 --- a/packages/cli/src/acp-integration/session/permissionUtils.test.ts +++ b/packages/cli/src/acp-integration/session/permissionUtils.test.ts @@ -34,6 +34,49 @@ describe('permissionUtils', () => { ); }); + it('returns plan options with RestorePrevious including prePlanMode', () => { + const options = toPermissionOptions({ + type: 'plan', + title: 'Would you like to proceed?', + plan: 'Test plan', + prePlanMode: 'yolo', + onConfirm: async () => undefined, + }); + + expect(options).toHaveLength(4); + expect(options[0]).toMatchObject({ + optionId: ToolConfirmationOutcome.RestorePrevious, + name: 'Yes, restore previous mode (yolo)', + kind: 'allow_once', + }); + expect(options[1]).toMatchObject({ + optionId: ToolConfirmationOutcome.ProceedAlways, + name: 'Yes, and auto-accept edits', + }); + expect(options[2]).toMatchObject({ + optionId: ToolConfirmationOutcome.ProceedOnce, + name: 'Yes, and manually approve edits', + }); + expect(options[3]).toMatchObject({ + optionId: ToolConfirmationOutcome.Cancel, + name: 'No, keep planning (esc)', + }); + }); + + it('defaults prePlanMode to "default" when not provided in plan options', () => { + const options = toPermissionOptions({ + type: 'plan', + title: 'Would you like to proceed?', + plan: 'Test plan', + onConfirm: async () => undefined, + }); + + expect(options[0]).toMatchObject({ + optionId: ToolConfirmationOutcome.RestorePrevious, + name: 'Yes, restore previous mode (default)', + }); + }); + it('falls back to rootCommand when exec permissionRules are unavailable', () => { const options = toPermissionOptions({ type: 'exec', diff --git a/packages/cli/src/acp-integration/session/permissionUtils.ts b/packages/cli/src/acp-integration/session/permissionUtils.ts index 06434b4a0..67d8244ed 100644 --- a/packages/cli/src/acp-integration/session/permissionUtils.ts +++ b/packages/cli/src/acp-integration/session/permissionUtils.ts @@ -171,6 +171,11 @@ export function toPermissionOptions( ); case 'plan': return [ + { + optionId: ToolConfirmationOutcome.RestorePrevious, + name: `Yes, restore previous mode (${confirmation.prePlanMode ?? 'default'})`, + kind: 'allow_once', + }, { optionId: ToolConfirmationOutcome.ProceedAlways, name: 'Yes, and auto-accept edits', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 6f028f957..d37e3d330 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1184,6 +1184,8 @@ export default { 'Always allow for this user': 'Für diesen Benutzer immer erlauben', 'Always allow {{action}} for this user': '{{action}} für diesen Benutzer immer erlauben', + 'Yes, restore previous mode ({{mode}})': + 'Ja, vorherigen Modus wiederherstellen ({{mode}})', 'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren', 'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen', 'No, keep planning (esc)': 'Nein, weiter planen (Esc)', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 065bb8b8f..f211ee1dd 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1236,6 +1236,8 @@ export default { 'Always allow for this user': 'Always allow for this user', 'Always allow {{action}} for this user': 'Always allow {{action}} for this user', + 'Yes, restore previous mode ({{mode}})': + 'Yes, restore previous mode ({{mode}})', 'Yes, and auto-accept edits': 'Yes, and auto-accept edits', 'Yes, and manually approve edits': 'Yes, and manually approve edits', 'No, keep planning (esc)': 'No, keep planning (esc)', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index df79083c0..af8bb2eb0 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -918,6 +918,8 @@ export default { 'このプロジェクトで{{action}}を常に許可', 'Always allow for this user': 'このユーザーに常に許可', 'Always allow {{action}} for this user': 'このユーザーに{{action}}を常に許可', + 'Yes, restore previous mode ({{mode}})': + 'はい、以前のモードに戻す ({{mode}})', 'Yes, and auto-accept edits': 'はい、編集を自動承認', 'Yes, and manually approve edits': 'はい、編集を手動承認', 'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 6c67fedb7..41b6810d6 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1190,6 +1190,8 @@ export default { 'Always allow for this user': 'Sempre permitir para este usuário', 'Always allow {{action}} for this user': 'Sempre permitir {{action}} para este usuário', + 'Yes, restore previous mode ({{mode}})': + 'Sim, restaurar modo anterior ({{mode}})', 'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente', 'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente', 'No, keep planning (esc)': 'Não, continuar planejando (esc)', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 3ccb15b67..79ab710c8 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1114,6 +1114,8 @@ export default { 'Always allow for this user': 'Всегда разрешать для этого пользователя', 'Always allow {{action}} for this user': 'Всегда разрешать {{action}} для этого пользователя', + 'Yes, restore previous mode ({{mode}})': + 'Да, восстановить предыдущий режим ({{mode}})', 'Yes, and auto-accept edits': 'Да, и автоматически принимать правки', 'Yes, and manually approve edits': 'Да, и вручную подтверждать правки', 'No, keep planning (esc)': 'Нет, продолжить планирование (esc)', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 3ac427a08..441d62548 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1170,6 +1170,7 @@ export default { 'Always allow {{action}} in this project': '在本项目中总是允许{{action}}', 'Always allow for this user': '对该用户总是允许', 'Always allow {{action}} for this user': '对该用户总是允许{{action}}', + 'Yes, restore previous mode ({{mode}})': '是,恢复之前的模式 ({{mode}})', 'Yes, and auto-accept edits': '是,并自动接受编辑', 'Yes, and manually approve edits': '是,并手动批准编辑', 'No, keep planning (esc)': '否,继续规划 (esc)', diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index cc70a8809..13e2b5028 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -302,6 +302,13 @@ export const ToolConfirmationMessage: React.FC< const planProps = confirmationDetails; question = planProps.title; + options.push({ + key: 'restore-previous', + label: t('Yes, restore previous mode ({{mode}})', { + mode: planProps.prePlanMode ?? 'default', + }), + value: ToolConfirmationOutcome.RestorePrevious, + }); options.push({ key: 'proceed-always', label: t('Yes, and auto-accept edits'), diff --git a/packages/core/src/telemetry/tool-call-decision.ts b/packages/core/src/telemetry/tool-call-decision.ts index b22a73c40..07c346e73 100644 --- a/packages/core/src/telemetry/tool-call-decision.ts +++ b/packages/core/src/telemetry/tool-call-decision.ts @@ -18,6 +18,7 @@ export function getDecisionFromOutcome( ): ToolCallDecision { switch (outcome) { case ToolConfirmationOutcome.ProceedOnce: + case ToolConfirmationOutcome.RestorePrevious: return ToolCallDecision.ACCEPT; case ToolConfirmationOutcome.ProceedAlways: case ToolConfirmationOutcome.ProceedAlwaysServer: diff --git a/packages/core/src/tools/exitPlanMode.test.ts b/packages/core/src/tools/exitPlanMode.test.ts index f1d80430b..f7f339aa0 100644 --- a/packages/core/src/tools/exitPlanMode.test.ts +++ b/packages/core/src/tools/exitPlanMode.test.ts @@ -201,6 +201,66 @@ describe('ExitPlanModeTool', () => { expect(approvalMode).toBe(ApprovalMode.DEFAULT); }); + it('should restore pre-plan mode on RestorePrevious', async () => { + (mockConfig.getPrePlanMode as ReturnType).mockReturnValue( + ApprovalMode.YOLO, + ); + + const params: ExitPlanModeParams = { plan: 'Restore previous test' }; + const signal = new AbortController().signal; + + const invocation = tool.build(params); + const confirmation = await invocation.getConfirmationDetails(signal); + + if (confirmation) { + await confirmation.onConfirm(ToolConfirmationOutcome.RestorePrevious); + } + + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.YOLO, + ); + expect(approvalMode).toBe(ApprovalMode.YOLO); + }); + + it('should include prePlanMode in confirmation details', async () => { + (mockConfig.getPrePlanMode as ReturnType).mockReturnValue( + ApprovalMode.AUTO_EDIT, + ); + + const params: ExitPlanModeParams = { plan: 'Test plan' }; + const signal = new AbortController().signal; + + const invocation = tool.build(params); + const confirmation = await invocation.getConfirmationDetails(signal); + + expect(confirmation).toMatchObject({ + type: 'plan', + prePlanMode: ApprovalMode.AUTO_EDIT, + }); + }); + + it('should fall back to DEFAULT on RestorePrevious when no prePlanMode recorded', async () => { + // getPrePlanMode() defaults to DEFAULT when prePlanMode is undefined + (mockConfig.getPrePlanMode as ReturnType).mockReturnValue( + ApprovalMode.DEFAULT, + ); + + const params: ExitPlanModeParams = { plan: 'Fallback test' }; + const signal = new AbortController().signal; + + const invocation = tool.build(params); + const confirmation = await invocation.getConfirmationDetails(signal); + + if (confirmation) { + await confirmation.onConfirm(ToolConfirmationOutcome.RestorePrevious); + } + + 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', diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index 03485e4cf..96f0c8610 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -87,12 +87,18 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation< override async getConfirmationDetails( _abortSignal: AbortSignal, ): Promise { + const prePlanMode = this.config.getPrePlanMode(); const details: ToolPlanConfirmationDetails = { type: 'plan', title: 'Would you like to proceed?', plan: this.params.plan, + prePlanMode, onConfirm: async (outcome: ToolConfirmationOutcome) => { switch (outcome) { + case ToolConfirmationOutcome.RestorePrevious: + this.wasApproved = true; + this.setApprovalModeSafely(prePlanMode); + break; case ToolConfirmationOutcome.ProceedAlways: this.wasApproved = true; this.setApprovalModeSafely(ApprovalMode.AUTO_EDIT); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 8e09dcd3c..0d50f351e 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -667,6 +667,8 @@ export interface ToolPlanConfirmationDetails { /** @see ToolEditConfirmationDetails.hideAlwaysAllow */ hideAlwaysAllow?: boolean; plan: string; + /** The approval mode that was active before entering plan mode (for display in the UI). */ + prePlanMode?: string; onConfirm: ( outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload, @@ -711,6 +713,8 @@ export enum ToolConfirmationOutcome { /** Persist the permission rule to the user settings (user scope). */ ProceedAlwaysUser = 'proceed_always_user', ModifyWithEditor = 'modify_with_editor', + /** Restore the approval mode that was active before entering plan mode. */ + RestorePrevious = 'restore_previous', Cancel = 'cancel', }