feat(plan): add exit_plan_mode confirmation type and dialog

Add the exit_plan_mode confirmation type and ExitPlanModeDialog component
for the plan approval workflow. The dialog reads plan content from a file
and offers approval modes (ApprovalMode.AUTO_EDIT for automatically accepting
edits, ApprovalMode.DEFAULT for manually accepting edits) or feedback option.
This commit is contained in:
Jerop Kipruto
2026-01-30 17:04:04 -05:00
parent 02283ca318
commit 203b5a8300
9 changed files with 647 additions and 7 deletions

View File

@@ -336,7 +336,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
interface OptionItem {
key: string;
label: string;
description: string;
description?: string;
type: 'option' | 'other' | 'done';
index: number;
}

View File

@@ -0,0 +1,233 @@
/**
* @license
* Copyright 2025 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 { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { ApprovalMode } from '@google/gemini-cli-core';
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(),
},
};
});
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
const waitForContentLoad = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
describe('ExitPlanModeDialog', () => {
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`;
let onApprove: ReturnType<typeof vi.fn>;
let onFeedback: ReturnType<typeof vi.fn>;
let onCancel: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.mocked(fs.promises.readFile).mockResolvedValue(samplePlanContent);
onApprove = vi.fn();
onFeedback = vi.fn();
onCancel = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
const renderDialog = () =>
renderWithProviders(
<ExitPlanModeDialog
planPath="/mock/plans/test-plan.md"
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
width={80}
availableHeight={20}
/>,
);
it('renders correctly with plan content', async () => {
const { lastFrame } = renderDialog();
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalledWith(
'/mock/plans/test-plan.md',
'utf8',
);
});
expect(lastFrame()).toMatchSnapshot();
});
it('calls onApprove with AUTO_EDIT when first option is selected', async () => {
const { stdin } = renderDialog();
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalled();
});
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
});
});
it('calls onApprove with DEFAULT when second option is selected', async () => {
const { stdin } = renderDialog();
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalled();
});
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
});
});
it('calls onFeedback when feedback is typed and submitted', async () => {
const { stdin, lastFrame } = renderDialog();
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalled();
});
// Navigate to feedback input
writeKey(stdin, '\x1b[B'); // Down arrow
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 { stdin } = renderDialog();
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalled();
});
writeKey(stdin, '\x1b'); // Escape
await waitFor(() => {
expect(onCancel).toHaveBeenCalled();
});
});
it('displays error state when file read fails', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
const { lastFrame } = renderDialog();
await waitForContentLoad();
expect(lastFrame()).toMatchSnapshot();
});
it('truncates very long plan content', async () => {
const longPlanContent = `## Overview
Implement a comprehensive authentication system with multiple providers.
## 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 OAuth2 provider support in \`src/auth/providers/OAuth2Provider.ts\`
5. Add SAML provider support in \`src/auth/providers/SAMLProvider.ts\`
6. Add LDAP provider support in \`src/auth/providers/LDAPProvider.ts\`
7. Create token refresh mechanism in \`src/auth/TokenManager.ts\`
8. Add multi-factor authentication in \`src/auth/MFAService.ts\`
9. Implement session timeout handling in \`src/auth/SessionManager.ts\`
10. Add audit logging for auth events in \`src/auth/AuditLogger.ts\`
11. Create user profile management in \`src/auth/UserProfile.ts\`
12. Add role-based access control in \`src/auth/RBACService.ts\`
13. Implement password policy enforcement in \`src/auth/PasswordPolicy.ts\`
14. Add brute force protection in \`src/auth/BruteForceGuard.ts\`
15. Create secure cookie handling in \`src/auth/CookieManager.ts\`
## Files to Modify
- \`src/index.ts\` - Add auth middleware
- \`src/config.ts\` - Add auth configuration options
- \`src/routes/api.ts\` - Add auth endpoints
- \`src/middleware/cors.ts\` - Update CORS for auth headers
- \`src/utils/crypto.ts\` - Add encryption utilities
## Testing Strategy
- Unit tests for each auth provider
- Integration tests for full auth flows
- Security penetration testing
- Load testing for session management`;
vi.mocked(fs.promises.readFile).mockResolvedValue(longPlanContent);
const { lastFrame } = renderDialog();
await waitForContentLoad();
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState, useEffect } from 'react';
import {
type Question,
QuestionType,
ApprovalMode,
} from '@google/gemini-cli-core';
import { AskUserDialog } from './AskUserDialog.js';
import * as fs from 'node:fs';
interface ExitPlanModeDialogProps {
planPath: string;
onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void;
onCancel: () => void;
width: number;
availableHeight: number;
}
const APPROVE_AUTO_EDIT = 'Yes, automatically accept edits';
const APPROVE_DEFAULT = 'Yes, manually accept edits';
interface PlanContentState {
status: 'loading' | 'loaded' | 'error';
content?: string;
error?: string;
}
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
planPath,
onApprove,
onFeedback,
onCancel,
width,
availableHeight,
}) => {
const [planState, setPlanState] = useState<PlanContentState>({
status: 'loading',
});
useEffect(() => {
let ignore = false;
fs.promises
.readFile(planPath, 'utf8')
.then((content) => {
if (ignore) return;
setPlanState({ status: 'loaded', content });
})
.catch((err) => {
if (ignore) return;
setPlanState({ status: 'error', error: err.message });
});
return () => {
ignore = true;
};
}, [planPath]);
const questions = useMemo((): Question[] => {
const context =
planState.status === 'error'
? `**Error reading plan:** ${planState.error}`
: planState.content;
return [
{
question: 'Ready to start implementation?',
header: 'Plan',
type: QuestionType.CHOICE,
options: [{ label: APPROVE_AUTO_EDIT }, { label: APPROVE_DEFAULT }],
context,
customOptionPlaceholder: 'Provide feedback...',
},
];
}, [planState]);
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],
);
return (
<AskUserDialog
questions={questions}
onSubmit={handleSubmit}
onCancel={onCancel}
width={width}
availableHeight={availableHeight}
/>
);
};

View File

@@ -0,0 +1,96 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExitPlanModeDialog > 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, automatically accept edits
2. Yes, manually accept edits
● 3. Add tests ✓
Enter to submit · Esc to cancel"
`;
exports[`ExitPlanModeDialog > displays error state when file read fails 1`] = `
"Error reading plan: File not found
Ready to start implementation?
● 1. Yes, automatically accept edits
2. Yes, manually accept edits
3. Provide feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`ExitPlanModeDialog > 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, automatically accept edits
2. Yes, manually accept edits
3. Provide feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`ExitPlanModeDialog > truncates very long plan content 1`] = `
"Overview
Implement a comprehensive authentication system with multiple providers.
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 OAuth2 provider support in src/auth/providers/OAuth2Provider.ts
5. Add SAML provider support in src/auth/providers/SAMLProvider.ts
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
7. Create token refresh mechanism in src/auth/TokenManager.ts
8. Add multi-factor authentication in src/auth/MFAService.ts
9. Implement session timeout handling in src/auth/SessionManager.ts
10. Add audit logging for auth events in src/auth/AuditLogger.ts
11. Create user profile management in src/auth/UserProfile.ts
12. Add role-based access control in src/auth/RBACService.ts
13. Implement password policy enforcement in src/auth/PasswordPolicy.ts
... last 17 lines hidden ...
Ready to start implementation?
● 1. Yes, automatically accept edits
2. Yes, manually accept edits
3. Provide feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;

View File

@@ -4,17 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
ToolCallConfirmationDetails,
Config,
SerializableConfirmationDetails,
} from '@google/gemini-cli-core';
import { ApprovalMode, ToolConfirmationOutcome } from '@google/gemini-cli-core';
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import * as fs from 'node:fs';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
@@ -27,8 +32,19 @@ vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
};
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
promises: {
...actual.promises,
readFile: vi.fn(),
},
};
});
describe('ToolConfirmationMessage', () => {
const mockConfirm = vi.fn();
const mockConfirm = vi.fn().mockResolvedValue(undefined);
vi.mocked(useToolActions).mockReturnValue({
confirm: mockConfirm,
cancel: vi.fn(),
@@ -363,4 +379,142 @@ describe('ToolConfirmationMessage', () => {
expect(lastFrame()).not.toContain('Modify with external editor');
});
});
describe('exit_plan_mode confirmation', () => {
const writeKey = (
stdin: { write: (data: string) => void },
key: string,
) => {
act(() => {
stdin.write(key);
});
};
const waitForContentLoad = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
beforeEach(() => {
vi.mocked(fs.promises.readFile).mockResolvedValue(
'## Test Plan\n\n1. Do something',
);
mockConfirm.mockClear();
mockConfirm.mockResolvedValue(undefined);
vi.mocked(useToolActions).mockReturnValue({
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
});
});
afterEach(() => {
vi.mocked(fs.promises.readFile).mockReset();
});
const exitPlanModeDetails: SerializableConfirmationDetails = {
type: 'exit_plan_mode',
title: 'Exit Plan Mode',
planPath: '/mock/plan.md',
};
it('passes approvalMode payload when first option is selected', async () => {
const { stdin } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={exitPlanModeDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitForContentLoad();
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(
'test-call-id',
ToolConfirmationOutcome.ProceedOnce,
{ approvalMode: ApprovalMode.AUTO_EDIT },
);
});
});
it('passes approvalMode payload when second option is selected', async () => {
const { stdin } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={exitPlanModeDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitForContentLoad();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(
'test-call-id',
ToolConfirmationOutcome.ProceedOnce,
{ approvalMode: ApprovalMode.DEFAULT },
);
});
});
it('passes feedback payload when feedback is submitted', async () => {
const { stdin } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={exitPlanModeDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitForContentLoad();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
for (const char of 'Add tests') {
writeKey(stdin, char);
}
writeKey(stdin, '\r');
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(
'test-call-id',
ToolConfirmationOutcome.Cancel,
{ feedback: 'Add tests' },
);
});
});
it('passes no payload when cancelled', async () => {
const { stdin } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={exitPlanModeDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitForContentLoad();
writeKey(stdin, '\x1b'); // Escape
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(
'test-call-id',
ToolConfirmationOutcome.Cancel,
undefined,
);
});
});
});
});

View File

@@ -34,6 +34,7 @@ import {
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -62,7 +63,9 @@ 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 === 'exit_plan_mode';
const isTrustedFolder = config.isTrustedFolder();
const handleConfirm = useCallback(
@@ -277,6 +280,28 @@ export const ToolConfirmationMessage: React.FC<
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'exit_plan_mode') {
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approvalMode,
});
}}
onFeedback={(feedback) => {
handleConfirm(ToolConfirmationOutcome.Cancel, { feedback });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight() ?? MINIMUM_MAX_HEIGHT}
/>
);
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;

View File

@@ -986,6 +986,9 @@ function toPermissionOptions(
case 'ask_user':
// askuser doesn't need "always allow" options since it's asking questions
return [...basicPermissionOptions];
case 'exit_plan_mode':
// exit_plan_mode doesn't need "always allow" options since it's asking a one-time question
return [...basicPermissionOptions];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);

View File

@@ -100,6 +100,11 @@ export type SerializableConfirmationDetails =
type: 'ask_user';
title: string;
questions: Question[];
}
| {
type: 'exit_plan_mode';
title: string;
planPath: string;
};
export interface UpdatePolicy {
@@ -130,7 +135,7 @@ export interface ToolExecutionFailure<E = Error> {
export interface QuestionOption {
label: string;
description: string;
description?: string;
}
export enum QuestionType {

View File

@@ -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.
@@ -701,9 +702,14 @@ export interface ToolAskUserConfirmationPayload {
answers: { [questionIndex: string]: string };
}
export type ToolExitPlanModeConfirmationPayload =
| { approvalMode: ApprovalMode }
| { feedback: string };
export type ToolConfirmationPayload =
| ToolEditConfirmationPayload
| ToolAskUserConfirmationPayload;
| ToolAskUserConfirmationPayload
| ToolExitPlanModeConfirmationPayload;
export interface ToolExecuteConfirmationDetails {
type: 'exec';
@@ -742,12 +748,23 @@ export interface ToolAskUserConfirmationDetails {
) => Promise<void>;
}
export interface ToolExitPlanModeConfirmationDetails {
type: 'exit_plan_mode';
title: string;
planPath: string;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
}
export type ToolCallConfirmationDetails =
| ToolEditConfirmationDetails
| ToolExecuteConfirmationDetails
| ToolMcpConfirmationDetails
| ToolInfoConfirmationDetails
| ToolAskUserConfirmationDetails;
| ToolAskUserConfirmationDetails
| ToolExitPlanModeConfirmationDetails;
export enum ToolConfirmationOutcome {
ProceedOnce = 'proceed_once',