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 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 {
|
interface OptionItem {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description?: string;
|
||||||
type: 'option' | 'other' | 'done';
|
type: 'option' | 'other' | 'done';
|
||||||
index: number;
|
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
|
* 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 { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||||
import type {
|
import type {
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
Config,
|
Config,
|
||||||
|
SerializableConfirmationDetails,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
import { ApprovalMode, ToolConfirmationOutcome } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
renderWithProviders,
|
renderWithProviders,
|
||||||
createMockSettings,
|
createMockSettings,
|
||||||
} from '../../../test-utils/render.js';
|
} from '../../../test-utils/render.js';
|
||||||
|
import { waitFor } from '../../../test-utils/async.js';
|
||||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
|
||||||
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
|
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
|
||||||
const actual =
|
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', () => {
|
describe('ToolConfirmationMessage', () => {
|
||||||
const mockConfirm = vi.fn();
|
const mockConfirm = vi.fn().mockResolvedValue(undefined);
|
||||||
vi.mocked(useToolActions).mockReturnValue({
|
vi.mocked(useToolActions).mockReturnValue({
|
||||||
confirm: mockConfirm,
|
confirm: mockConfirm,
|
||||||
cancel: vi.fn(),
|
cancel: vi.fn(),
|
||||||
@@ -363,4 +379,142 @@ describe('ToolConfirmationMessage', () => {
|
|||||||
expect(lastFrame()).not.toContain('Modify with external editor');
|
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,
|
REDIRECTION_WARNING_TIP_TEXT,
|
||||||
} from '../../textConstants.js';
|
} from '../../textConstants.js';
|
||||||
import { AskUserDialog } from '../AskUserDialog.js';
|
import { AskUserDialog } from '../AskUserDialog.js';
|
||||||
|
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||||
|
|
||||||
export interface ToolConfirmationMessageProps {
|
export interface ToolConfirmationMessageProps {
|
||||||
callId: string;
|
callId: string;
|
||||||
@@ -62,7 +63,9 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
const allowPermanentApproval =
|
const allowPermanentApproval =
|
||||||
settings.merged.security.enablePermanentToolApproval;
|
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 isTrustedFolder = config.isTrustedFolder();
|
||||||
|
|
||||||
const handleConfirm = useCallback(
|
const handleConfirm = useCallback(
|
||||||
@@ -277,6 +280,28 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
return { question: '', bodyContent, options: [] };
|
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.type === 'edit') {
|
||||||
if (!confirmationDetails.isModifying) {
|
if (!confirmationDetails.isModifying) {
|
||||||
question = `Apply this change?`;
|
question = `Apply this change?`;
|
||||||
|
|||||||
@@ -986,6 +986,9 @@ function toPermissionOptions(
|
|||||||
case 'ask_user':
|
case 'ask_user':
|
||||||
// askuser doesn't need "always allow" options since it's asking questions
|
// askuser doesn't need "always allow" options since it's asking questions
|
||||||
return [...basicPermissionOptions];
|
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: {
|
default: {
|
||||||
const unreachable: never = confirmation;
|
const unreachable: never = confirmation;
|
||||||
throw new Error(`Unexpected: ${unreachable}`);
|
throw new Error(`Unexpected: ${unreachable}`);
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ export type SerializableConfirmationDetails =
|
|||||||
type: 'ask_user';
|
type: 'ask_user';
|
||||||
title: string;
|
title: string;
|
||||||
questions: Question[];
|
questions: Question[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'exit_plan_mode';
|
||||||
|
title: string;
|
||||||
|
planPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UpdatePolicy {
|
export interface UpdatePolicy {
|
||||||
@@ -130,7 +135,7 @@ export interface ToolExecutionFailure<E = Error> {
|
|||||||
|
|
||||||
export interface QuestionOption {
|
export interface QuestionOption {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QuestionType {
|
export enum QuestionType {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type ToolConfirmationResponse,
|
type ToolConfirmationResponse,
|
||||||
type Question,
|
type Question,
|
||||||
} from '../confirmation-bus/types.js';
|
} from '../confirmation-bus/types.js';
|
||||||
|
import type { ApprovalMode } from '../policy/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a validated and ready-to-execute tool call.
|
* Represents a validated and ready-to-execute tool call.
|
||||||
@@ -701,9 +702,14 @@ export interface ToolAskUserConfirmationPayload {
|
|||||||
answers: { [questionIndex: string]: string };
|
answers: { [questionIndex: string]: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ToolExitPlanModeConfirmationPayload =
|
||||||
|
| { approvalMode: ApprovalMode }
|
||||||
|
| { feedback: string };
|
||||||
|
|
||||||
export type ToolConfirmationPayload =
|
export type ToolConfirmationPayload =
|
||||||
| ToolEditConfirmationPayload
|
| ToolEditConfirmationPayload
|
||||||
| ToolAskUserConfirmationPayload;
|
| ToolAskUserConfirmationPayload
|
||||||
|
| ToolExitPlanModeConfirmationPayload;
|
||||||
|
|
||||||
export interface ToolExecuteConfirmationDetails {
|
export interface ToolExecuteConfirmationDetails {
|
||||||
type: 'exec';
|
type: 'exec';
|
||||||
@@ -742,12 +748,23 @@ export interface ToolAskUserConfirmationDetails {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolExitPlanModeConfirmationDetails {
|
||||||
|
type: 'exit_plan_mode';
|
||||||
|
title: string;
|
||||||
|
planPath: string;
|
||||||
|
onConfirm: (
|
||||||
|
outcome: ToolConfirmationOutcome,
|
||||||
|
payload?: ToolConfirmationPayload,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export type ToolCallConfirmationDetails =
|
export type ToolCallConfirmationDetails =
|
||||||
| ToolEditConfirmationDetails
|
| ToolEditConfirmationDetails
|
||||||
| ToolExecuteConfirmationDetails
|
| ToolExecuteConfirmationDetails
|
||||||
| ToolMcpConfirmationDetails
|
| ToolMcpConfirmationDetails
|
||||||
| ToolInfoConfirmationDetails
|
| ToolInfoConfirmationDetails
|
||||||
| ToolAskUserConfirmationDetails;
|
| ToolAskUserConfirmationDetails
|
||||||
|
| ToolExitPlanModeConfirmationDetails;
|
||||||
|
|
||||||
export enum ToolConfirmationOutcome {
|
export enum ToolConfirmationOutcome {
|
||||||
ProceedOnce = 'proceed_once',
|
ProceedOnce = 'proceed_once',
|
||||||
|
|||||||
Reference in New Issue
Block a user