From d72dce66f4f2520b36db6fc3eed055234e429474 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Thu, 29 Jan 2026 23:10:58 -0500 Subject: [PATCH] feat(plan): add exit_plan_mode tool with plan approval dialog Introduce a new tool for exiting plan mode that presents users with a dedicated approval dialog. Users can review the plan content and choose to approve (with auto-edit or default approval modes) or provide feedback for revisions. --- .../src/ui/components/AskUserDialog.test.tsx | 168 +++++++++++++ .../cli/src/ui/components/AskUserDialog.tsx | 7 +- .../ui/components/PlanApprovalDialog.test.tsx | 173 ++++++++++++++ .../src/ui/components/PlanApprovalDialog.tsx | 107 +++++++++ .../ui/components/ToolConfirmationQueue.tsx | 5 +- .../__snapshots__/AskUserDialog.test.tsx.snap | 101 ++++++++ .../PlanApprovalDialog.test.tsx.snap | 69 ++++++ .../messages/ToolConfirmationMessage.tsx | 46 +++- .../cli/src/ui/contexts/UIActionsContext.tsx | 8 +- .../hooks/useToolExecutionScheduler.test.ts | 2 +- .../cli/src/zed-integration/zedIntegration.ts | 3 + packages/core/src/confirmation-bus/types.ts | 11 + .../core/src/core/coreToolScheduler.test.ts | 5 +- packages/core/src/core/coreToolScheduler.ts | 2 +- packages/core/src/policy/policies/plan.toml | 6 + .../core/src/scheduler/confirmation.test.ts | 2 +- packages/core/src/scheduler/confirmation.ts | 10 +- .../core/src/scheduler/tool-modifier.test.ts | 11 +- packages/core/src/scheduler/tool-modifier.ts | 4 +- packages/core/src/tools/ask-user.test.ts | 2 + packages/core/src/tools/ask-user.ts | 2 +- .../core/src/tools/exit-plan-mode.test.ts | 221 ++++++++++++++++++ packages/core/src/tools/exit-plan-mode.ts | 207 ++++++++++++++++ packages/core/src/tools/tool-names.ts | 1 + packages/core/src/tools/tools.ts | 68 +++++- 25 files changed, 1205 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/ui/components/PlanApprovalDialog.test.tsx create mode 100644 packages/cli/src/ui/components/PlanApprovalDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/PlanApprovalDialog.test.tsx.snap create mode 100644 packages/core/src/tools/exit-plan-mode.test.ts create mode 100644 packages/core/src/tools/exit-plan-mode.ts diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index df8fd959b5..64366960e1 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -918,4 +918,172 @@ describe('AskUserDialog', () => { }); }); }); + + describe('Question content field', () => { + it('renders content in a choice question', () => { + const questionWithContent: Question[] = [ + { + question: 'Approve this plan?', + header: 'Plan', + options: [{ label: 'Yes', description: 'Approve and proceed' }], + content: + '## Plan Details\n\n- Step 1: Do something\n- Step 2: Do another thing', + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders content in a text question', () => { + const questionWithContent: Question[] = [ + { + question: 'Enter your feedback:', + header: 'Feedback', + type: QuestionType.TEXT, + content: + 'Please review the following changes before providing feedback:\n\n- Changed file A\n- Updated file B', + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('does not render content when content is not provided', () => { + const questionWithoutContent: Question[] = [ + { + question: 'Simple question?', + header: 'Simple', + options: [{ label: 'Yes', description: '' }], + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('uses customOptionPlaceholder for the Other option', () => { + const questionWithCustomPlaceholder: Question[] = [ + { + question: 'Approve this?', + header: 'Approve', + options: [{ label: 'Yes', description: '' }], + customOptionPlaceholder: 'Enter your feedback here...', + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('truncates long content when availableTerminalHeight is small', async () => { + const longContent = Array.from( + { length: 30 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + const questionWithLongContent: Question[] = [ + { + question: 'Approve this plan?', + header: 'Plan', + options: [{ label: 'Yes', description: '' }], + content: longContent, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { + width: 120, + uiState: { + availableTerminalHeight: 15, + }, + }, + ); + + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('does not truncate content when availableTerminalHeight is undefined', () => { + const content = Array.from( + { length: 10 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + const questionWithContent: Question[] = [ + { + question: 'Approve this plan?', + header: 'Plan', + options: [{ label: 'Yes', description: '' }], + content, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { + width: 120, + uiState: { + availableTerminalHeight: undefined, + }, + }, + ); + + expect(lastFrame()).not.toContain('lines hidden'); + expect(lastFrame()).toMatchSnapshot(); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index ac535ce991..c3568b35fd 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -702,10 +702,9 @@ const ChoiceQuestionView: React.FC = ({ const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; const listHeight = Math.max(1, availableHeight - overhead); const questionHeight = Math.min(3, Math.max(1, listHeight - 4)); - const maxItemsToShow = Math.max( - 1, - Math.floor((listHeight - questionHeight) / 2), - ); + const maxItemsToShow = question.showAllOptions + ? selectionItems.length + : Math.max(1, Math.floor((listHeight - questionHeight) / 2)); return ( diff --git a/packages/cli/src/ui/components/PlanApprovalDialog.test.tsx b/packages/cli/src/ui/components/PlanApprovalDialog.test.tsx new file mode 100644 index 0000000000..293c5ddf43 --- /dev/null +++ b/packages/cli/src/ui/components/PlanApprovalDialog.test.tsx @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { PlanApprovalDialog } from './PlanApprovalDialog.js'; +import * as fs from 'node:fs'; + +// Mock only the fs.promises.readFile method, keeping the rest of the module +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + readFile: vi.fn(), + }, + }; +}); + +// Helper to write to stdin with proper act() wrapping +const writeKey = (stdin: { write: (data: string) => void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +// Helper to wait for content to be loaded with act() +const waitForContentLoad = async () => { + // Allow the promise to resolve and state to update + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +describe('PlanApprovalDialog', () => { + const samplePlanContent = `## Overview + +Add user authentication to the CLI application. + +## Implementation Steps + +1. Create \`src/auth/AuthService.ts\` with login/logout methods +2. Add session storage in \`src/storage/SessionStore.ts\` +3. Update \`src/commands/index.ts\` to check auth status +4. Add tests in \`src/auth/__tests__/\` + +## Files to Modify + +- \`src/index.ts\` - Add auth middleware +- \`src/config.ts\` - Add auth configuration options`; + + beforeEach(() => { + vi.mocked(fs.promises.readFile).mockResolvedValue(samplePlanContent); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const defaultProps = { + planPath: '/mock/plans/test-plan.md', + onApprove: vi.fn(), + onFeedback: vi.fn(), + onCancel: vi.fn(), + }; + + it('renders correctly with plan content', async () => { + const { lastFrame } = renderWithProviders( + , + ); + + await waitForContentLoad(); + + await waitFor(() => { + expect(fs.promises.readFile).toHaveBeenCalledWith( + '/mock/plans/test-plan.md', + 'utf8', + ); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('calls onApprove when approve option is selected', async () => { + const onApprove = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + await waitForContentLoad(); + + await waitFor(() => { + expect(fs.promises.readFile).toHaveBeenCalled(); + }); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onApprove).toHaveBeenCalled(); + }); + }); + + it('calls onFeedback when feedback is typed and submitted', async () => { + const onFeedback = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + ); + + await waitForContentLoad(); + + await waitFor(() => { + expect(fs.promises.readFile).toHaveBeenCalled(); + }); + + // Navigate past the approve option to the feedback input + writeKey(stdin, '\x1b[B'); // Down arrow + + for (const char of 'Add tests') { + writeKey(stdin, char); + } + + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onFeedback).toHaveBeenCalledWith('Add tests'); + }); + }); + + it('calls onCancel when Esc is pressed', async () => { + const onCancel = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + await waitForContentLoad(); + + await waitFor(() => { + expect(fs.promises.readFile).toHaveBeenCalled(); + }); + + writeKey(stdin, '\x1b'); // Escape + + await waitFor(() => { + expect(onCancel).toHaveBeenCalled(); + }); + }); + + it('handles file read error gracefully', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValue( + new Error('File not found'), + ); + + const { lastFrame } = renderWithProviders( + , + ); + + await waitForContentLoad(); + + await waitFor(() => { + expect(lastFrame()).toContain('Error reading plan file'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/PlanApprovalDialog.tsx b/packages/cli/src/ui/components/PlanApprovalDialog.tsx new file mode 100644 index 0000000000..8416b7d822 --- /dev/null +++ b/packages/cli/src/ui/components/PlanApprovalDialog.tsx @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState, useEffect, useContext } from 'react'; +import { + type Question, + QuestionType, + ApprovalMode, +} from '@google/gemini-cli-core'; +import { AskUserDialog } from './AskUserDialog.js'; +import { UIStateContext } from '../contexts/UIStateContext.js'; +import * as fs from 'node:fs'; + +interface PlanApprovalDialogProps { + planPath: string; + /** Called when user approves the plan with the selected approval mode. */ + onApprove: (approvalMode: ApprovalMode) => void; + onFeedback: (feedback: string) => void; + onCancel: () => void; +} + +const APPROVE_AUTO_EDIT = 'Yes, automatically accept edits'; +const APPROVE_DEFAULT = 'Yes, manually accept edits'; + +export const PlanApprovalDialog: React.FC = ({ + planPath, + onApprove, + onFeedback, + onCancel, +}) => { + const uiState = useContext(UIStateContext); + const [planContent, setPlanContent] = useState(undefined); + + useEffect(() => { + let ignore = false; + + fs.promises + .readFile(planPath, 'utf8') + .then((content) => { + if (ignore) return; + setPlanContent(content); + }) + .catch((err) => { + if (ignore) return; + setPlanContent(`Error reading plan file: ${err.message}`); + }); + + return () => { + ignore = true; + }; + }, [planPath]); + + const questions = useMemo( + (): Question[] => [ + { + question: 'Ready to start implementation?', + header: 'Plan', + type: QuestionType.CHOICE, + options: [ + { + label: APPROVE_AUTO_EDIT, + description: 'Edits will be applied without confirmation', + }, + { + label: APPROVE_DEFAULT, + description: 'You will be asked to confirm each edit', + }, + ], + content: planContent, + customOptionPlaceholder: 'Provide feedback...', + showAllOptions: true, + }, + ], + [planContent], + ); + + const handleSubmit = useCallback( + (answers: { [questionIndex: string]: string }) => { + const answer = answers['0']; + if (answer === APPROVE_AUTO_EDIT) { + onApprove(ApprovalMode.AUTO_EDIT); + } else if (answer === APPROVE_DEFAULT) { + onApprove(ApprovalMode.DEFAULT); + } else if (answer) { + onFeedback(answer); + } + }, + [onApprove, onFeedback], + ); + + const width = uiState?.mainAreaWidth ?? 80; + const availableHeight = uiState?.availableTerminalHeight ?? 20; + + return ( + + ); +}; diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index 0ee6fec05c..63d09a8430 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -25,6 +25,7 @@ function getConfirmationHeader( Record > = { ask_user: 'Answer Questions', + plan_approval: 'Plan Approval', }; if (!details?.type) { return 'Action Required'; @@ -70,7 +71,9 @@ export const ToolConfirmationQueue: React.FC = ({ : undefined; const borderColor = theme.status.warning; - const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user'; + const hideToolIdentity = + tool.confirmationDetails?.type === 'ask_user' || + tool.confirmationDetails?.type === 'plan_approval'; return ( diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 7b70584fa9..01ac6fec61 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -1,5 +1,106 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`AskUserDialog > Question content field > does not render content when content is not provided 1`] = ` +"Simple question? + +▲ +● 1. Yes + 2. Enter a custom value +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + +exports[`AskUserDialog > Question content field > does not truncate content when availableTerminalHeight is undefined 1`] = ` +"Line 1 +Line 2 +Line 3 +Line 4 +Line 5 +Line 6 +Line 7 +Line 8 +Line 9 +Line 10 + +Approve this plan? + +▲ +● 1. Yes + 2. Enter a custom value +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + +exports[`AskUserDialog > Question content field > renders content in a choice question 1`] = ` +"Plan Details + + - Step 1: Do something + - Step 2: Do another thing + +Approve this plan? + +▲ +● 1. Yes + Approve and proceed + 2. Enter a custom value +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + +exports[`AskUserDialog > Question content field > renders content in a text question 1`] = ` +"Please review the following changes before providing feedback: + + - Changed file A + - Updated file B + +Enter your feedback: + +> Enter your response + + +Enter to submit · Esc to cancel" +`; + +exports[`AskUserDialog > Question content field > truncates long content when availableTerminalHeight is small 1`] = ` +"... first 28 lines hidden ... +Line 29 +Line 30 + +Approve this plan? + +▲ +● 1. Yes + 2. Enter a custom value +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + +exports[`AskUserDialog > Question content field > truncates long content when availableTerminalHeight is small 2`] = ` +"Approve this plan? + +▲ +● 1. Yes + 2. Enter a custom value +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + +exports[`AskUserDialog > Question content field > uses customOptionPlaceholder for the Other option 1`] = ` +"Approve this? + +▲ +● 1. Yes + 2. Enter your feedback here... +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; + exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` "What should we name this component? diff --git a/packages/cli/src/ui/components/__snapshots__/PlanApprovalDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PlanApprovalDialog.test.tsx.snap new file mode 100644 index 0000000000..2e840ba884 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/PlanApprovalDialog.test.tsx.snap @@ -0,0 +1,69 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PlanApprovalDialog > calls onFeedback when feedback is typed and submitted 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + +Ready to start implementation? + +▲ + 1. Yes, start implementation +● 2. Add tests ✓ +▼ + +Enter to submit · Esc to cancel" +`; + +exports[`PlanApprovalDialog > calls onFeedback when feedback is typed and submitted 2`] = ` +"Ready to start implementation? + +▲ + 1. Yes, automatically accept edits + Edits will be applied without confirmation + 2. Yes, manually accept edits + You will be asked to confirm each edit +● 3. Add tests ✓ +▼ + +Enter to submit · Esc to cancel" +`; + +exports[`PlanApprovalDialog > renders correctly with plan content 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + +Ready to start implementation? + +▲ +● 1. Yes, start implementation + 2. Provide feedback... +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" +`; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index e58d0ca600..cb6e7789ef 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -10,6 +10,7 @@ import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; import { + type ApprovalMode, type SerializableConfirmationDetails, type ToolCallConfirmationDetails, type Config, @@ -33,6 +34,7 @@ import { REDIRECTION_WARNING_TIP_TEXT, } from '../../textConstants.js'; import { AskUserDialog } from '../AskUserDialog.js'; +import { PlanApprovalDialog } from '../PlanApprovalDialog.js'; export interface ToolConfirmationMessageProps { callId: string; @@ -61,13 +63,22 @@ export const ToolConfirmationMessage: React.FC< const allowPermanentApproval = settings.merged.security.enablePermanentToolApproval; - const handlesOwnUI = confirmationDetails.type === 'ask_user'; + const handlesOwnUI = + confirmationDetails.type === 'ask_user' || + confirmationDetails.type === 'plan_approval'; const isTrustedFolder = config.isTrustedFolder(); const handleConfirm = useCallback( ( outcome: ToolConfirmationOutcome, - payload?: { answers?: { [questionIndex: string]: string } }, + payload?: + | { type: 'ask_user'; answers: { [questionIndex: string]: string } } + | { + type: 'plan_approval'; + approved: boolean; + approvalMode?: ApprovalMode; + feedback?: string; + }, ) => { void confirm(callId, outcome, payload).catch((error: unknown) => { debugLogger.error( @@ -267,7 +278,10 @@ export const ToolConfirmationMessage: React.FC< { - handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers }); + handleConfirm(ToolConfirmationOutcome.ProceedOnce, { + type: 'ask_user', + answers, + }); }} onCancel={() => { handleConfirm(ToolConfirmationOutcome.Cancel); @@ -279,6 +293,32 @@ export const ToolConfirmationMessage: React.FC< return { question: '', bodyContent, options: [] }; } + if (confirmationDetails.type === 'plan_approval') { + bodyContent = ( + { + handleConfirm(ToolConfirmationOutcome.ProceedOnce, { + type: 'plan_approval', + approved: true, + approvalMode, + }); + }} + onFeedback={(feedback: string) => { + handleConfirm(ToolConfirmationOutcome.ProceedOnce, { + type: 'plan_approval', + approved: false, + feedback, + }); + }} + onCancel={() => { + handleConfirm(ToolConfirmationOutcome.Cancel); + }} + /> + ); + return { question: '', bodyContent, options: [] }; + } + if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { question = `Apply this change?`; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4eb8584ae3..0bfb38cf79 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -8,10 +8,10 @@ import { createContext, useContext } from 'react'; import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js'; import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; -import { - type AuthType, - type EditorType, - type AgentDefinition, +import type { + AuthType, + EditorType, + AgentDefinition, } from '@google/gemini-cli-core'; import { type LoadableSettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; diff --git a/packages/cli/src/ui/hooks/useToolExecutionScheduler.test.ts b/packages/cli/src/ui/hooks/useToolExecutionScheduler.test.ts index 797109499b..27b517f059 100644 --- a/packages/cli/src/ui/hooks/useToolExecutionScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolExecutionScheduler.test.ts @@ -232,7 +232,7 @@ describe('useToolExecutionScheduler', () => { call.confirmationDetails as ToolCallConfirmationDetails; const publishSpy = vi.spyOn(mockMessageBus, 'publish'); - const mockPayload = { newContent: 'updated code' }; + const mockPayload = { type: 'edit' as const, newContent: 'updated code' }; await confirmationDetails.onConfirm( ToolConfirmationOutcome.ProceedOnce, mockPayload, diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 7273c0b961..6bcc9a371b 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -986,6 +986,9 @@ function toPermissionOptions( case 'ask_user': // askuser doesn't need "always allow" options since it's asking questions return [...basicPermissionOptions]; + case 'plan_approval': + // plan_approval doesn't need "always allow" options since it's a one-time approval + return [...basicPermissionOptions]; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index fcdd600f3c..a2a2af376b 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -100,6 +100,11 @@ export type SerializableConfirmationDetails = type: 'ask_user'; title: string; questions: Question[]; + } + | { + type: 'plan_approval'; + title: string; + planPath: string; }; export interface UpdatePolicy { @@ -150,6 +155,12 @@ export interface Question { multiSelect?: boolean; /** Placeholder hint text. Only applies when type='text'. */ placeholder?: string; + /** Markdown content to display before the question. */ + content?: string; + /** Placeholder for the custom "Other" option input. Only applies when type='choice'. Defaults to 'Enter a custom value'. */ + customOptionPlaceholder?: string; + /** When true, all options are shown without scrolling. Only applies when type='choice'. */ + showAllOptions?: boolean; } export interface AskUserRequest { diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 154b975638..7939aa25fc 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -752,7 +752,10 @@ describe('CoreToolScheduler with payload', () => { const confirmationDetails = awaitingCall.confirmationDetails; if (confirmationDetails) { - const payload: ToolConfirmationPayload = { newContent: 'final version' }; + const payload: ToolConfirmationPayload = { + type: 'edit', + newContent: 'final version', + }; await (confirmationDetails as ToolCallConfirmationDetails).onConfirm( ToolConfirmationOutcome.ProceedOnce, payload, diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 124cef32b9..77e7925ea0 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -789,7 +789,7 @@ export class CoreToolScheduler { } else { // If the client provided new content, apply it and wait for // re-confirmation. - if (payload?.newContent && toolCall) { + if (payload?.type === 'edit' && payload.newContent && toolCall) { const result = await this.toolModifier.applyInlineModify( toolCall as WaitingToolCall, payload, diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 5b8b1d7882..8172bbfccf 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -70,6 +70,12 @@ decision = "ask_user" priority = 50 modes = ["plan"] +[[rule]] +toolName = "exit_plan_mode" +decision = "ask_user" +priority = 50 +modes = ["plan"] + # Allow write_file for .md files in plans directory [[rule]] toolName = "write_file" diff --git a/packages/core/src/scheduler/confirmation.test.ts b/packages/core/src/scheduler/confirmation.test.ts index 9bfdba2184..5426aa0e68 100644 --- a/packages/core/src/scheduler/confirmation.test.ts +++ b/packages/core/src/scheduler/confirmation.test.ts @@ -367,7 +367,7 @@ describe('confirmation.ts', () => { correlationId: '123e4567-e89b-12d3-a456-426614174000', confirmed: true, outcome: ToolConfirmationOutcome.ProceedOnce, // Ignored if payload present - payload: { newContent: 'inline' }, + payload: { type: 'edit', newContent: 'inline' }, }); mockModifier.applyInlineModify.mockResolvedValue({ diff --git a/packages/core/src/scheduler/confirmation.ts b/packages/core/src/scheduler/confirmation.ts index 76ab6fac97..87923631b8 100644 --- a/packages/core/src/scheduler/confirmation.ts +++ b/packages/core/src/scheduler/confirmation.ts @@ -16,6 +16,7 @@ import { ToolConfirmationOutcome, type ToolConfirmationPayload, type ToolCallConfirmationDetails, + type EditConfirmationPayload, } from '../tools/tools.js'; import type { ValidatingToolCall, WaitingToolCall } from './types.js'; import type { Config } from '../config/config.js'; @@ -156,7 +157,10 @@ export async function resolveConfirmation( if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { await handleExternalModification(deps, toolCall, signal); - } else if (response.payload?.newContent) { + } else if ( + response.payload?.type === 'edit' && + response.payload.newContent + ) { await handleInlineModification(deps, toolCall, response.payload, signal); outcome = ToolConfirmationOutcome.ProceedOnce; } @@ -219,7 +223,7 @@ async function handleExternalModification( async function handleInlineModification( deps: { state: SchedulerStateManager; modifier: ToolModificationHandler }, toolCall: ValidatingToolCall, - payload: ToolConfirmationPayload, + payload: EditConfirmationPayload, signal: AbortSignal, ): Promise { const { state, modifier } = deps; @@ -277,7 +281,7 @@ async function waitForConfirmation( ? ToolConfirmationOutcome.ProceedOnce : ToolConfirmationOutcome.Cancel, payload: resolution.content - ? { newContent: resolution.content } + ? ({ type: 'edit', newContent: resolution.content } as const) : undefined, }) as ConfirmationResult, ) diff --git a/packages/core/src/scheduler/tool-modifier.test.ts b/packages/core/src/scheduler/tool-modifier.test.ts index 8107e4c901..92feb2a3ff 100644 --- a/packages/core/src/scheduler/tool-modifier.test.ts +++ b/packages/core/src/scheduler/tool-modifier.test.ts @@ -13,7 +13,7 @@ import { MockModifiableTool, MockTool } from '../test-utils/mock-tool.js'; import type { ToolResult, ToolInvocation, - ToolConfirmationPayload, + EditConfirmationPayload, } from '../tools/tools.js'; import type { ModifyContext } from '../tools/modifiable-tool.js'; import type { Mock } from 'vitest'; @@ -175,7 +175,7 @@ describe('ToolModificationHandler', () => { const result = await handler.applyInlineModify( mockWaitingToolCall, - { newContent: 'foo' }, + { type: 'edit', newContent: 'foo' }, new AbortController().signal, ); @@ -193,7 +193,10 @@ describe('ToolModificationHandler', () => { const result = await handler.applyInlineModify( mockWaitingToolCall, - { newContent: undefined } as unknown as ToolConfirmationPayload, + { + type: 'edit', + newContent: undefined, + } as unknown as EditConfirmationPayload, new AbortController().signal, ); @@ -225,7 +228,7 @@ describe('ToolModificationHandler', () => { const result = await handler.applyInlineModify( mockWaitingToolCall, - { newContent: 'new content' }, + { type: 'edit', newContent: 'new content' }, new AbortController().signal, ); diff --git a/packages/core/src/scheduler/tool-modifier.ts b/packages/core/src/scheduler/tool-modifier.ts index c7d9c93c67..7633f1adf4 100644 --- a/packages/core/src/scheduler/tool-modifier.ts +++ b/packages/core/src/scheduler/tool-modifier.ts @@ -11,7 +11,7 @@ import { modifyWithEditor, type ModifyContext, } from '../tools/modifiable-tool.js'; -import type { ToolConfirmationPayload } from '../tools/tools.js'; +import type { EditConfirmationPayload } from '../tools/tools.js'; import type { WaitingToolCall } from './types.js'; export interface ModificationResult { @@ -65,7 +65,7 @@ export class ToolModificationHandler { */ async applyInlineModify( toolCall: WaitingToolCall, - payload: ToolConfirmationPayload, + payload: EditConfirmationPayload, signal: AbortSignal, ): Promise { if ( diff --git a/packages/core/src/tools/ask-user.test.ts b/packages/core/src/tools/ask-user.test.ts index da41ff45f2..0bf758851b 100644 --- a/packages/core/src/tools/ask-user.test.ts +++ b/packages/core/src/tools/ask-user.test.ts @@ -307,6 +307,7 @@ describe('AskUserTool', () => { if (details && 'onConfirm' in details) { const answers = { '0': 'Quick fix (Recommended)' }; await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + type: 'ask_user', answers, }); } @@ -341,6 +342,7 @@ describe('AskUserTool', () => { // Simulate confirmation with empty answers if (details && 'onConfirm' in details) { await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + type: 'ask_user', answers: {}, }); } diff --git a/packages/core/src/tools/ask-user.ts b/packages/core/src/tools/ask-user.ts index 0e1989967b..b2329fc2cd 100644 --- a/packages/core/src/tools/ask-user.ts +++ b/packages/core/src/tools/ask-user.ts @@ -180,7 +180,7 @@ export class AskUserInvocation extends BaseToolInvocation< payload?: ToolConfirmationPayload, ) => { this.confirmationOutcome = outcome; - if (payload?.answers) { + if (payload?.type === 'ask_user' && payload.answers) { this.userAnswers = payload.answers; } }, diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts new file mode 100644 index 0000000000..c4a5bccda5 --- /dev/null +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ExitPlanModeTool } from './exit-plan-mode.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import path from 'node:path'; +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { ToolConfirmationOutcome } from './tools.js'; +import { ApprovalMode } from '../policy/types.js'; + +describe('ExitPlanModeTool', () => { + let tool: ExitPlanModeTool; + let mockMessageBus: ReturnType; + let mockConfig: Partial; + + const mockTargetDir = path.resolve('/mock/dir'); + const mockPlansDir = path.resolve('/mock/dir/plans'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + mockMessageBus = createMockMessageBus(); + vi.mocked(mockMessageBus.publish).mockResolvedValue(undefined); + mockConfig = { + getTargetDir: vi.fn().mockReturnValue(mockTargetDir), + setApprovalMode: vi.fn(), + storage: { + getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir), + } as unknown as Config['storage'], + }; + tool = new ExitPlanModeTool( + mockConfig as Config, + mockMessageBus as unknown as MessageBus, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('shouldConfirmExecute', () => { + it('should return plan approval confirmation details', async () => { + const planPath = 'plans/test-plan.md'; + const invocation = tool.build({ plan_path: planPath }); + + const result = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(result).not.toBe(false); + if (result === false) return; + + expect(result.type).toBe('plan_approval'); + expect(result.title).toBe('Plan Approval'); + if (result.type === 'plan_approval') { + expect(result.planPath).toBe( + path.resolve(mockPlansDir, 'test-plan.md'), + ); + } + expect(typeof result.onConfirm).toBe('function'); + }); + + it('should return false if plan path is invalid', async () => { + // Create a tool with a plan path that resolves outside the plans directory + const invocation = tool.build({ plan_path: 'plans/valid.md' }); + + // Override getProjectTempPlansDir to make the validation fail + vi.mocked(mockConfig.storage!.getProjectTempPlansDir).mockReturnValue( + '/completely/different/path', + ); + + const result = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(result).toBe(false); + }); + }); + + describe('execute', () => { + it('should return approval message when plan is approved', async () => { + const planPath = 'plans/test-plan.md'; + const invocation = tool.build({ plan_path: planPath }); + + // Simulate the confirmation flow + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + // Call onConfirm with approval (using default approval mode) + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + type: 'plan_approval', + approved: true, + approvalMode: ApprovalMode.DEFAULT, + }); + + const result = await invocation.execute(new AbortController().signal); + const expectedPath = path.resolve(mockPlansDir, 'test-plan.md'); + + expect(result).toEqual({ + llmContent: `Plan approved. Switching to Default mode (edits will require confirmation). + +The approved implementation plan is stored at: ${expectedPath} +Read and follow the plan strictly during implementation.`, + returnDisplay: `Plan approved: ${expectedPath}`, + }); + }); + + it('should return feedback message when plan is rejected', async () => { + const planPath = 'plans/test-plan.md'; + const invocation = tool.build({ plan_path: planPath }); + + // Simulate the confirmation flow + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + // Call onConfirm with rejection and feedback + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + type: 'plan_approval', + approved: false, + feedback: 'Please add more details.', + }); + + const result = await invocation.execute(new AbortController().signal); + const expectedPath = path.resolve(mockPlansDir, 'test-plan.md'); + + expect(result).toEqual({ + llmContent: `Plan rejected. Feedback: Please add more details. + +The plan is stored at: ${expectedPath} +Revise the plan based on the feedback.`, + returnDisplay: 'Feedback: Please add more details.', + }); + }); + + it('should return cancellation message when cancelled', async () => { + const planPath = 'plans/test-plan.md'; + const invocation = tool.build({ plan_path: planPath }); + + // Simulate the confirmation flow + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + // Call onConfirm with cancellation + await confirmDetails.onConfirm(ToolConfirmationOutcome.Cancel); + + const result = await invocation.execute(new AbortController().signal); + + expect(result).toEqual({ + llmContent: + 'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.', + returnDisplay: 'Cancelled', + }); + }); + + it('should return error for invalid plan path', async () => { + const planPath = 'plans/test-plan.md'; + const invocation = tool.build({ plan_path: planPath }); + + // Override getProjectTempPlansDir to make the validation fail + vi.mocked(mockConfig.storage!.getProjectTempPlansDir).mockReturnValue( + '/completely/different/path', + ); + + const result = await invocation.execute(new AbortController().signal); + + expect(result).toEqual({ + llmContent: + 'Error: Plan path is outside the designated plans directory.', + returnDisplay: + 'Error: Plan path is outside the designated plans directory.', + }); + }); + }); + + it('should throw error during build if plan path is outside plans directory', () => { + expect(() => tool.build({ plan_path: '../../../etc/passwd' })).toThrow( + /Access denied/, + ); + }); + + describe('validateToolParams', () => { + it('should reject empty plan_path', () => { + const result = tool.validateToolParams({ plan_path: '' }); + expect(result).toBe('plan_path is required.'); + }); + + it('should reject whitespace-only plan_path', () => { + const result = tool.validateToolParams({ plan_path: ' ' }); + expect(result).toBe('plan_path is required.'); + }); + + it('should reject path outside plans directory', () => { + const result = tool.validateToolParams({ + plan_path: '../../../etc/passwd', + }); + expect(result).toContain('Access denied'); + }); + + it('should accept valid path within plans directory', () => { + const result = tool.validateToolParams({ + plan_path: 'plans/valid-plan.md', + }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts new file mode 100644 index 0000000000..f6e5279832 --- /dev/null +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + type ToolResult, + Kind, + type ToolPlanApprovalConfirmationDetails, + type ToolConfirmationPayload, + type PlanApprovalConfirmationPayload, + ToolConfirmationOutcome, +} from './tools.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import path from 'node:path'; +import type { Config } from '../config/config.js'; +import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js'; +import { isWithinRoot } from '../utils/fileUtils.js'; +import { ApprovalMode } from '../policy/types.js'; + +/** + * Returns a human-readable description for an approval mode. + */ +function getApprovalModeDescription(mode: ApprovalMode): string { + switch (mode) { + case ApprovalMode.AUTO_EDIT: + return 'Auto-Edit mode (edits will be applied automatically)'; + case ApprovalMode.DEFAULT: + default: + return 'Default mode (edits will require confirmation)'; + } +} + +export interface ExitPlanModeParams { + plan_path: string; +} + +export class ExitPlanModeTool extends BaseDeclarativeTool< + ExitPlanModeParams, + ToolResult +> { + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + EXIT_PLAN_MODE_TOOL_NAME, + 'Exit Plan Mode', + 'Signals that the planning phase is complete and requests user approval to start implementation.', + Kind.Plan, + { + type: 'object', + required: ['plan_path'], + properties: { + plan_path: { + type: 'string', + description: + 'The file path to the finalized plan (e.g., "plans/feature-x.md").', + }, + }, + }, + messageBus, + ); + } + + protected override validateToolParamValues( + params: ExitPlanModeParams, + ): string | null { + if (!params.plan_path || params.plan_path.trim() === '') { + return 'plan_path is required.'; + } + + const resolvedPath = path.resolve( + this.config.getTargetDir(), + params.plan_path, + ); + + const plansDir = this.config.storage.getProjectTempPlansDir(); + if (!isWithinRoot(resolvedPath, plansDir)) { + return `Access denied: plan path must be within the designated plans directory (${plansDir}).`; + } + + return null; + } + + protected createInvocation( + params: ExitPlanModeParams, + messageBus: MessageBus, + toolName: string, + toolDisplayName: string, + ): ExitPlanModeInvocation { + return new ExitPlanModeInvocation( + params, + messageBus, + toolName, + toolDisplayName, + this.config, + ); + } +} + +export class ExitPlanModeInvocation extends BaseToolInvocation< + ExitPlanModeParams, + ToolResult +> { + private confirmationOutcome: ToolConfirmationOutcome | null = null; + private approvalPayload: PlanApprovalConfirmationPayload | null = null; + + constructor( + params: ExitPlanModeParams, + messageBus: MessageBus, + toolName: string, + toolDisplayName: string, + private config: Config, + ) { + super(params, messageBus, toolName, toolDisplayName); + } + + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + const resolvedPlanPath = this.getValidatedPlanPath(); + if (!resolvedPlanPath) { + return false; + } + + return { + type: 'plan_approval', + title: 'Plan Approval', + planPath: resolvedPlanPath, + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + this.confirmationOutcome = outcome; + if (payload?.type === 'plan_approval') { + this.approvalPayload = payload; + } + }, + }; + } + + getDescription(): string { + return `Requesting plan approval for: ${this.params.plan_path}`; + } + + /** + * Returns the resolved plan path if valid, or null if outside the plans directory. + */ + private getValidatedPlanPath(): string | null { + const plansDir = this.config.storage.getProjectTempPlansDir(); + const resolvedPath = path.resolve( + this.config.getTargetDir(), + this.params.plan_path, + ); + return isWithinRoot(resolvedPath, plansDir) ? resolvedPath : null; + } + + async execute(_signal: AbortSignal): Promise { + const resolvedPlanPath = this.getValidatedPlanPath(); + if (!resolvedPlanPath) { + return { + llmContent: + 'Error: Plan path is outside the designated plans directory.', + returnDisplay: + 'Error: Plan path is outside the designated plans directory.', + }; + } + + if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) { + return { + llmContent: + 'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.', + returnDisplay: 'Cancelled', + }; + } + + const payload = this.approvalPayload; + if (payload?.approved) { + // Set the approval mode based on user's choice + const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT; + this.config.setApprovalMode(newMode); + + const description = getApprovalModeDescription(newMode); + + return { + llmContent: `Plan approved. Switching to ${description}. + +The approved implementation plan is stored at: ${resolvedPlanPath} +Read and follow the plan strictly during implementation.`, + returnDisplay: `Plan approved: ${resolvedPlanPath}`, + }; + } else { + const feedback = payload?.feedback || 'None'; + return { + llmContent: `Plan rejected. Feedback: ${feedback} + +The plan is stored at: ${resolvedPlanPath} +Revise the plan based on the feedback.`, + returnDisplay: `Feedback: ${feedback}`, + }; + } + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index ee3eb8f930..141fb59a18 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,6 +25,7 @@ export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill'; export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]); export const ASK_USER_TOOL_NAME = 'ask_user'; export const ASK_USER_DISPLAY_NAME = 'Ask User'; +export const EXIT_PLAN_MODE_TOOL_NAME = 'exit_plan_mode'; /** Prefix used for tools discovered via the toolDiscoveryCommand. */ export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_'; diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 265df05dd5..6070e8425f 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -18,6 +18,7 @@ import { type ToolConfirmationResponse, type Question, } from '../confirmation-bus/types.js'; +import type { ApprovalMode } from '../policy/types.js'; /** * Represents a validated and ready-to-execute tool call. @@ -688,18 +689,47 @@ export interface ToolEditConfirmationDetails { ideConfirmation?: Promise; } -export interface ToolConfirmationPayload { - // used to override `modifiedProposedContent` for modifiable tools in the - // inline modify flow - newContent?: string; - // used for askuser tool to return user's answers - answers?: { [questionIndex: string]: string }; +/** + * Discriminated union for tool confirmation payloads. + * Each tool type has its own payload shape. + */ +export type ToolConfirmationPayload = + | EditConfirmationPayload + | AskUserConfirmationPayload + | PlanApprovalConfirmationPayload; + +/** Payload for edit tool confirmations (inline modify flow) */ +export interface EditConfirmationPayload { + type: 'edit'; + /** Modified content from inline editor */ + newContent: string; +} + +/** Payload for ask_user tool confirmations */ +export interface AskUserConfirmationPayload { + type: 'ask_user'; + /** User's answers keyed by question index */ + answers: { [questionIndex: string]: string }; +} + +/** Payload for plan approval confirmations */ +export interface PlanApprovalConfirmationPayload { + type: 'plan_approval'; + /** Whether the user approved the plan */ + approved: boolean; + /** If approved, the approval mode to use for implementation */ + approvalMode?: ApprovalMode; + /** If rejected, the user's feedback */ + feedback?: string; } export interface ToolExecuteConfirmationDetails { type: 'exec'; title: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; command: string; rootCommand: string; rootCommands: string[]; @@ -712,13 +742,19 @@ export interface ToolMcpConfirmationDetails { serverName: string; toolName: string; toolDisplayName: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; } export interface ToolInfoConfirmationDetails { type: 'info'; title: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; prompt: string; urls?: string[]; } @@ -733,12 +769,23 @@ export interface ToolAskUserConfirmationDetails { ) => Promise; } +export interface ToolPlanApprovalConfirmationDetails { + type: 'plan_approval'; + title: string; + planPath: string; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + export type ToolCallConfirmationDetails = | ToolEditConfirmationDetails | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails | ToolInfoConfirmationDetails - | ToolAskUserConfirmationDetails; + | ToolAskUserConfirmationDetails + | ToolPlanApprovalConfirmationDetails; export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', @@ -760,6 +807,7 @@ export enum Kind { Think = 'think', Fetch = 'fetch', Communicate = 'communicate', + Plan = 'plan', Other = 'other', }