mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat(plan): add exit_plan_mode ui and prompt (#18162)
This commit is contained in:
535
packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Normal file
535
packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* @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 { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
validatePlanContent,
|
||||
processSingleFileContent,
|
||||
type FileSystemService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
validatePlanPath: vi.fn(async () => null),
|
||||
validatePlanContent: vi.fn(async () => null),
|
||||
processSingleFileContent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
};
|
||||
|
||||
describe('ExitPlanModeDialog', () => {
|
||||
const mockTargetDir = '/mock/project';
|
||||
const mockPlansDir = '/mock/project/plans';
|
||||
const mockPlanFullPath = '/mock/project/plans/test-plan.md';
|
||||
|
||||
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`;
|
||||
|
||||
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`;
|
||||
|
||||
let onApprove: ReturnType<typeof vi.fn>;
|
||||
let onFeedback: ReturnType<typeof vi.fn>;
|
||||
let onCancel: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: samplePlanContent,
|
||||
returnDisplay: 'Read file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||
onApprove = vi.fn();
|
||||
onFeedback = vi.fn();
|
||||
onCancel = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderDialog = (options?: { useAlternateBuffer?: boolean }) =>
|
||||
renderWithProviders(
|
||||
<ExitPlanModeDialog
|
||||
planPath={mockPlanFullPath}
|
||||
onApprove={onApprove}
|
||||
onFeedback={onFeedback}
|
||||
onCancel={onCancel}
|
||||
width={80}
|
||||
availableHeight={24}
|
||||
/>,
|
||||
{
|
||||
...options,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])(
|
||||
'useAlternateBuffer: $useAlternateBuffer',
|
||||
({ useAlternateBuffer }) => {
|
||||
it('renders correctly with plan content', async () => {
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
// Advance timers to pass the debounce period
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(processSingleFileContent).toHaveBeenCalledWith(
|
||||
mockPlanFullPath,
|
||||
mockPlansDir,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onApprove with AUTO_EDIT when first option is selected', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onApprove with DEFAULT when second option is selected', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
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({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type feedback
|
||||
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, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b'); // Escape
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays error state when file read fails', async () => {
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: '',
|
||||
returnDisplay: '',
|
||||
error: 'File not found',
|
||||
});
|
||||
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Error reading plan: File not found');
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('displays error state when plan file is empty', async () => {
|
||||
vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.');
|
||||
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'Error reading plan: Plan file is empty.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles long plan content appropriately', async () => {
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: longPlanContent,
|
||||
returnDisplay: 'Read file',
|
||||
});
|
||||
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'Implement a comprehensive authentication system',
|
||||
);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('allows number key quick selection', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Press '2' to select second option directly
|
||||
writeKey(stdin, '2');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears feedback text when Ctrl+C is pressed while editing', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option and start typing
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test feedback') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('test feedback');
|
||||
});
|
||||
|
||||
// Press Ctrl+C to clear
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('test feedback');
|
||||
expect(lastFrame()).toContain('Type your feedback...');
|
||||
});
|
||||
|
||||
// Dialog should still be open (not cancelled)
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bubbles up Ctrl+C when feedback is empty while editing', async () => {
|
||||
const onBubbledQuit = vi.fn();
|
||||
|
||||
const BubbleListener = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
onBubbledQuit();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<BubbleListener>
|
||||
<ExitPlanModeDialog
|
||||
planPath={mockPlanFullPath}
|
||||
onApprove={onApprove}
|
||||
onFeedback={onFeedback}
|
||||
onCancel={onCancel}
|
||||
width={80}
|
||||
availableHeight={24}
|
||||
/>
|
||||
</BubbleListener>,
|
||||
{
|
||||
useAlternateBuffer,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('test');
|
||||
});
|
||||
|
||||
// First Ctrl+C to clear text
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(onBubbledQuit).not.toHaveBeenCalled();
|
||||
|
||||
// Second Ctrl+C to exit (should bubble)
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBubbledQuit).toHaveBeenCalled();
|
||||
});
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit empty feedback when Enter is pressed', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
|
||||
// Press Enter without typing anything
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// Wait a bit to ensure no callback was triggered
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
|
||||
expect(onFeedback).not.toHaveBeenCalled();
|
||||
expect(onApprove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows arrow navigation while typing feedback to change selection', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option and start typing
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
// Now use up arrow to navigate back to a different option
|
||||
writeKey(stdin, '\x1b[A'); // Up arrow
|
||||
|
||||
// Press Enter to select the second option (manually accept edits)
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
|
||||
});
|
||||
expect(onFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
226
packages/cli/src/ui/components/ExitPlanModeDialog.tsx
Normal file
226
packages/cli/src/ui/components/ExitPlanModeDialog.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
ApprovalMode,
|
||||
validatePlanPath,
|
||||
validatePlanContent,
|
||||
QuestionType,
|
||||
type Config,
|
||||
processSingleFileContent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
|
||||
export interface ExitPlanModeDialogProps {
|
||||
planPath: string;
|
||||
onApprove: (approvalMode: ApprovalMode) => void;
|
||||
onFeedback: (feedback: string) => void;
|
||||
onCancel: () => void;
|
||||
width: number;
|
||||
availableHeight?: number;
|
||||
}
|
||||
|
||||
enum PlanStatus {
|
||||
Loading = 'loading',
|
||||
Loaded = 'loaded',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
interface PlanContentState {
|
||||
status: PlanStatus;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
enum ApprovalOption {
|
||||
Auto = 'Yes, automatically accept edits',
|
||||
Manual = 'Yes, manually accept edits',
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny component for loading and error states with consistent styling.
|
||||
*/
|
||||
const StatusMessage: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => <Box paddingX={1}>{children}</Box>;
|
||||
|
||||
function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
const [state, setState] = useState<PlanContentState>({
|
||||
status: PlanStatus.Loading,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setState({ status: PlanStatus.Loading });
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getTargetDir(),
|
||||
);
|
||||
if (ignore) return;
|
||||
if (pathError) {
|
||||
setState({ status: PlanStatus.Error, error: pathError });
|
||||
return;
|
||||
}
|
||||
|
||||
const contentError = await validatePlanContent(planPath);
|
||||
if (ignore) return;
|
||||
if (contentError) {
|
||||
setState({ status: PlanStatus.Error, error: contentError });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
|
||||
if (ignore) return;
|
||||
|
||||
if (result.error) {
|
||||
setState({ status: PlanStatus.Error, error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof result.llmContent !== 'string') {
|
||||
setState({
|
||||
status: PlanStatus.Error,
|
||||
error: 'Plan file format not supported (binary or image).',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const content = result.llmContent;
|
||||
if (!content) {
|
||||
setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
|
||||
return;
|
||||
}
|
||||
setState({ status: PlanStatus.Loaded, content });
|
||||
} catch (err: unknown) {
|
||||
if (ignore) return;
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setState({ status: PlanStatus.Error, error: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [planPath, config]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
planPath,
|
||||
onApprove,
|
||||
onFeedback,
|
||||
onCancel,
|
||||
width,
|
||||
availableHeight,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const planState = usePlanContent(planPath, config);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (planState.status !== PlanStatus.Loading) {
|
||||
setShowLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setShowLoading(true);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [planState.status]);
|
||||
|
||||
if (planState.status === PlanStatus.Loading) {
|
||||
if (!showLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusMessage>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
Loading plan...
|
||||
</Text>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (planState.status === PlanStatus.Error) {
|
||||
return (
|
||||
<StatusMessage>
|
||||
<Text color={theme.status.error}>
|
||||
Error reading plan: {planState.error}
|
||||
</Text>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
const planContent = planState.content?.trim();
|
||||
if (!planContent) {
|
||||
return (
|
||||
<StatusMessage>
|
||||
<Text color={theme.status.error}>Error: Plan content is empty.</Text>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<AskUserDialog
|
||||
questions={[
|
||||
{
|
||||
type: QuestionType.CHOICE,
|
||||
header: 'Approval',
|
||||
question: planContent,
|
||||
options: [
|
||||
{
|
||||
label: ApprovalOption.Auto,
|
||||
description:
|
||||
'Approves plan and allows tools to run automatically',
|
||||
},
|
||||
{
|
||||
label: ApprovalOption.Manual,
|
||||
description:
|
||||
'Approves plan but requires confirmation for each tool',
|
||||
},
|
||||
],
|
||||
placeholder: 'Type your feedback...',
|
||||
multiSelect: false,
|
||||
},
|
||||
]}
|
||||
onSubmit={(answers) => {
|
||||
const answer = answers['0'];
|
||||
if (answer === ApprovalOption.Auto) {
|
||||
onApprove(ApprovalMode.AUTO_EDIT);
|
||||
} else if (answer === ApprovalOption.Manual) {
|
||||
onApprove(ApprovalMode.DEFAULT);
|
||||
} else if (answer) {
|
||||
onFeedback(answer);
|
||||
}
|
||||
}}
|
||||
onCancel={onCancel}
|
||||
width={width}
|
||||
availableHeight={availableHeight}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ function getConfirmationHeader(
|
||||
Record<SerializableConfirmationDetails['type'], string>
|
||||
> = {
|
||||
ask_user: 'Answer Questions',
|
||||
exit_plan_mode: 'Ready to start implementation?',
|
||||
};
|
||||
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 === 'exit_plan_mode';
|
||||
|
||||
return (
|
||||
<OverflowProvider>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > handles long plan content appropriately 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\`
|
||||
... last 22 lines hidden ...
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > 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
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > handles long plan content appropriately 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\`
|
||||
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
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > 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
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
@@ -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,32 @@ 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, {
|
||||
approved: true,
|
||||
approvalMode,
|
||||
});
|
||||
}}
|
||||
onFeedback={(feedback) => {
|
||||
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
approved: false,
|
||||
feedback,
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}}
|
||||
width={terminalWidth}
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
question = `Apply this change?`;
|
||||
|
||||
@@ -198,12 +198,12 @@ The following read-only tools are available in Plan Mode:
|
||||
- Only begin this phase after exploration is complete
|
||||
- Create a detailed implementation plan with clear steps
|
||||
- Include file paths, function signatures, and code snippets where helpful
|
||||
- After saving the plan, present the full content of the markdown file to the user for review
|
||||
- Save the implementation plan to the designated plans directory
|
||||
|
||||
### Phase 4: Review & Approval
|
||||
- Ask the user if they approve the plan, want revisions, or want to reject it
|
||||
- Address feedback and iterate as needed
|
||||
- **When the user approves the plan**, prompt them to switch out of Plan Mode to begin implementation by pressing Shift+Tab to cycle to a different approval mode
|
||||
- Present the plan and request approval for the finalized plan using the \`exit_plan_mode\` tool
|
||||
- If plan is approved, you can begin implementation
|
||||
- If plan is rejected, address the feedback and iterate on the plan
|
||||
|
||||
## Constraints
|
||||
- You may ONLY use the read-only tools listed above
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
MEMORY_TOOL_NAME,
|
||||
@@ -326,12 +327,12 @@ ${options.planModeToolsList}
|
||||
- Only begin this phase after exploration is complete
|
||||
- Create a detailed implementation plan with clear steps
|
||||
- Include file paths, function signatures, and code snippets where helpful
|
||||
- After saving the plan, present the full content of the markdown file to the user for review
|
||||
- Save the implementation plan to the designated plans directory
|
||||
|
||||
### Phase 4: Review & Approval
|
||||
- Ask the user if they approve the plan, want revisions, or want to reject it
|
||||
- Address feedback and iterate as needed
|
||||
- **When the user approves the plan**, prompt them to switch out of Plan Mode to begin implementation by pressing Shift+Tab to cycle to a different approval mode
|
||||
- Present the plan and request approval for the finalized plan using the \`${EXIT_PLAN_MODE_TOOL_NAME}\` tool
|
||||
- If plan is approved, you can begin implementation
|
||||
- If plan is rejected, address the feedback and iterate on the plan
|
||||
|
||||
## Constraints
|
||||
- You may ONLY use the read-only tools listed above
|
||||
|
||||
Reference in New Issue
Block a user