diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 9d521ada3b..6cc52adff1 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -336,7 +336,7 @@ const TextQuestionView: React.FC = ({ interface OptionItem { key: string; label: string; - description: string; + description?: string; type: 'option' | 'other' | 'done'; index: number; } diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx new file mode 100644 index 0000000000..18858bf7c7 --- /dev/null +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -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(); + 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; + let onFeedback: ReturnType; + let onCancel: ReturnType; + + beforeEach(() => { + vi.mocked(fs.promises.readFile).mockResolvedValue(samplePlanContent); + onApprove = vi.fn(); + onFeedback = vi.fn(); + onCancel = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const renderDialog = () => + renderWithProviders( + , + ); + + 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(); + }); +}); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx new file mode 100644 index 0000000000..5d6fb99ba5 --- /dev/null +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -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 = ({ + planPath, + onApprove, + onFeedback, + onCancel, + width, + availableHeight, +}) => { + const [planState, setPlanState] = useState({ + 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 ( + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap new file mode 100644 index 0000000000..e79eb30f30 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -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" +`; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 9489ad1d23..bf9d5c5aa8 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -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(); + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + await waitForContentLoad(); + writeKey(stdin, '\x1b'); // Escape + + await waitFor(() => { + expect(mockConfirm).toHaveBeenCalledWith( + 'test-call-id', + ToolConfirmationOutcome.Cancel, + undefined, + ); + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 2272c1a4dd..b380d18533 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -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 = ( + { + 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?`; diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 7273c0b961..aece2a14d3 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -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}`); diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index 74b8708fce..77101b4821 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -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 { export interface QuestionOption { label: string; - description: string; + description?: string; } export enum QuestionType { diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 9c308ecba6..135258fbf1 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -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; } +export interface ToolExitPlanModeConfirmationDetails { + type: 'exit_plan_mode'; + title: string; + planPath: string; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + export type ToolCallConfirmationDetails = | ToolEditConfirmationDetails | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails | ToolInfoConfirmationDetails - | ToolAskUserConfirmationDetails; + | ToolAskUserConfirmationDetails + | ToolExitPlanModeConfirmationDetails; export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once',