mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 14:44:29 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
233
packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Normal file
233
packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
107
packages/cli/src/ui/components/ExitPlanModeDialog.tsx
Normal file
107
packages/cli/src/ui/components/ExitPlanModeDialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
`;
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?`;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user