mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
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.
This commit is contained in:
@@ -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(
|
||||
<AskUserDialog
|
||||
questions={questionWithContent}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ 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(
|
||||
<AskUserDialog
|
||||
questions={questionWithContent}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ 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(
|
||||
<AskUserDialog
|
||||
questions={questionWithoutContent}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ 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(
|
||||
<AskUserDialog
|
||||
questions={questionWithCustomPlaceholder}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ 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(
|
||||
<AskUserDialog
|
||||
questions={questionWithLongContent}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={15}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<AskUserDialog
|
||||
questions={questionWithContent}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
availableTerminalHeight: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('lines hidden');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -702,10 +702,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
|
||||
173
packages/cli/src/ui/components/PlanApprovalDialog.test.tsx
Normal file
173
packages/cli/src/ui/components/PlanApprovalDialog.test.tsx
Normal file
@@ -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<typeof fs>();
|
||||
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(
|
||||
<PlanApprovalDialog {...defaultProps} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanApprovalDialog {...defaultProps} onApprove={onApprove} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanApprovalDialog {...defaultProps} onFeedback={onFeedback} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanApprovalDialog {...defaultProps} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanApprovalDialog {...defaultProps} />,
|
||||
);
|
||||
|
||||
await waitForContentLoad();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Error reading plan file');
|
||||
});
|
||||
});
|
||||
});
|
||||
107
packages/cli/src/ui/components/PlanApprovalDialog.tsx
Normal file
107
packages/cli/src/ui/components/PlanApprovalDialog.tsx
Normal file
@@ -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<PlanApprovalDialogProps> = ({
|
||||
planPath,
|
||||
onApprove,
|
||||
onFeedback,
|
||||
onCancel,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const [planContent, setPlanContent] = useState<string | undefined>(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 (
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
width={width}
|
||||
availableHeight={availableHeight}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ function getConfirmationHeader(
|
||||
Record<SerializableConfirmationDetails['type'], string>
|
||||
> = {
|
||||
ask_user: 'Answer Questions',
|
||||
plan_approval: 'Plan Approval',
|
||||
};
|
||||
if (!details?.type) {
|
||||
return 'Action Required';
|
||||
@@ -70,7 +71,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
: 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 (
|
||||
<OverflowProvider>
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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"
|
||||
`;
|
||||
@@ -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<
|
||||
<AskUserDialog
|
||||
questions={confirmationDetails.questions}
|
||||
onSubmit={(answers) => {
|
||||
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 = (
|
||||
<PlanApprovalDialog
|
||||
planPath={confirmationDetails.planPath}
|
||||
onApprove={(approvalMode: ApprovalMode) => {
|
||||
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?`;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<ModificationResult | undefined> {
|
||||
if (
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
221
packages/core/src/tools/exit-plan-mode.test.ts
Normal file
221
packages/core/src/tools/exit-plan-mode.test.ts
Normal file
@@ -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<typeof createMockMessageBus>;
|
||||
let mockConfig: Partial<Config>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
207
packages/core/src/tools/exit-plan-mode.ts
Normal file
207
packages/core/src/tools/exit-plan-mode.ts
Normal file
@@ -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<ToolPlanApprovalConfirmationDetails | false> {
|
||||
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<ToolResult> {
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_';
|
||||
|
||||
@@ -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<DiffUpdateResult>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>;
|
||||
command: string;
|
||||
rootCommand: string;
|
||||
rootCommands: string[];
|
||||
@@ -712,13 +742,19 @@ export interface ToolMcpConfirmationDetails {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolDisplayName: string;
|
||||
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ToolInfoConfirmationDetails {
|
||||
type: 'info';
|
||||
title: string;
|
||||
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>;
|
||||
prompt: string;
|
||||
urls?: string[];
|
||||
}
|
||||
@@ -733,12 +769,23 @@ export interface ToolAskUserConfirmationDetails {
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ToolPlanApprovalConfirmationDetails {
|
||||
type: 'plan_approval';
|
||||
title: string;
|
||||
planPath: string;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user