Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/test-utils/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ const mockUIActions: UIActions = {
onHintSubmit: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
getPreferredEditor: vi.fn(),
};

let capturedOverflowState: OverflowState | undefined;
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2554,6 +2554,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setNewAgents(null);
},
getPreferredEditor,
}),
[
handleThemeSelect,
Expand Down Expand Up @@ -2605,6 +2606,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
newAgents,
config,
historyManager,
getPreferredEditor,
],
);

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/ui/components/AskUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,20 +183,26 @@ interface AskUserDialogProps {
* Height constraint for scrollable content.
*/
availableHeight?: number;
/**
* Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"])
*/
extraParts?: string[];
}

interface ReviewViewProps {
questions: Question[];
answers: { [key: string]: string };
onSubmit: () => void;
progressHeader?: React.ReactNode;
extraParts?: string[];
}

const ReviewView: React.FC<ReviewViewProps> = ({
questions,
answers,
onSubmit,
progressHeader,
extraParts,
}) => {
const unansweredCount = questions.length - Object.keys(answers).length;
const hasUnanswered = unansweredCount > 0;
Expand Down Expand Up @@ -247,6 +253,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
<DialogFooter
primaryAction="Enter to submit"
navigationActions="Tab/Shift+Tab to edit answers"
extraParts={extraParts}
/>
</Box>
);
Expand Down Expand Up @@ -925,6 +932,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onActiveTextInputChange,
width,
availableHeight: availableHeightProp,
extraParts,
}) => {
const uiState = useContext(UIStateContext);
const availableHeight =
Expand Down Expand Up @@ -1120,6 +1128,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
answers={answers}
onSubmit={handleReviewSubmit}
progressHeader={progressHeader}
extraParts={extraParts}
/>
</Box>
);
Expand All @@ -1143,6 +1152,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
? undefined
: '↑/↓ to navigate'
}
extraParts={extraParts}
/>
);

Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 { openFileInEditor } from '../utils/editorUtils.js';
import {
ApprovalMode,
validatePlanContent,
Expand All @@ -19,6 +20,10 @@ import {
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';

vi.mock('../utils/editorUtils.js', () => ({
openFileInEditor: vi.fn(),
}));

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
Expand Down Expand Up @@ -144,6 +149,7 @@ Implement a comprehensive authentication system with multiple providers.
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>,
Expand All @@ -153,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers.
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
getPreferredEditor: () => undefined,
storage: {
getPlansDir: () => mockPlansDir,
},
Expand Down Expand Up @@ -418,6 +425,7 @@ Implement a comprehensive authentication system with multiple providers.
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
Expand Down Expand Up @@ -535,6 +543,40 @@ Implement a comprehensive authentication system with multiple providers.
});
expect(onFeedback).not.toHaveBeenCalled();
});

it('opens plan in external editor when Ctrl+X is pressed', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });

await act(async () => {
vi.runAllTimers();
});

await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});

// Reset the mock to track the second call during refresh
vi.mocked(processSingleFileContent).mockClear();

// Press Ctrl+X
await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X
});

await waitFor(() => {
expect(openFileInEditor).toHaveBeenCalledWith(
mockPlanFullPath,
expect.anything(),
expect.anything(),
undefined,
);
});

// Verify that content is refreshed (processSingleFileContent called again)
await waitFor(() => {
expect(processSingleFileContent).toHaveBeenCalled();
});
});
},
);
});
49 changes: 44 additions & 5 deletions packages/cli/src/ui/components/ExitPlanModeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,32 @@
*/

import type React from 'react';
import { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { useEffect, useState, useCallback } from 'react';
import { Box, Text, useStdin } from 'ink';
import {
ApprovalMode,
validatePlanPath,
validatePlanContent,
QuestionType,
type Config,
type EditorType,
processSingleFileContent,
debugLogger,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { AskUserDialog } from './AskUserDialog.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { formatCommand } from '../utils/keybindingUtils.js';

export interface ExitPlanModeDialogProps {
planPath: string;
onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void;
onCancel: () => void;
getPreferredEditor: () => EditorType | undefined;
width: number;
availableHeight?: number;
}
Expand All @@ -38,6 +45,7 @@ interface PlanContentState {
status: PlanStatus;
content?: string;
error?: string;
refresh: () => void;
}

enum ApprovalOption {
Expand All @@ -53,10 +61,15 @@ const StatusMessage: React.FC<{
}> = ({ children }) => <Box paddingX={1}>{children}</Box>;

function usePlanContent(planPath: string, config: Config): PlanContentState {
const [state, setState] = useState<PlanContentState>({
const [version, setVersion] = useState(0);
const [state, setState] = useState<Omit<PlanContentState, 'refresh'>>({
status: PlanStatus.Loading,
});

const refresh = useCallback(() => {
setVersion((v) => v + 1);
}, []);

useEffect(() => {
let ignore = false;
setState({ status: PlanStatus.Loading });
Expand Down Expand Up @@ -120,23 +133,46 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
return () => {
ignore = true;
};
}, [planPath, config]);
}, [planPath, config, version]);

return state;
return { ...state, refresh };
}

export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
planPath,
onApprove,
onFeedback,
onCancel,
getPreferredEditor,
width,
availableHeight,
}) => {
const config = useConfig();
const { stdin, setRawMode } = useStdin();
const planState = usePlanContent(planPath, config);
const { refresh } = planState;
const [showLoading, setShowLoading] = useState(false);

const handleOpenEditor = useCallback(async () => {
try {
await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor());
refresh();
} catch (err) {
debugLogger.error('Failed to open plan in editor:', err);
}
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh]);

useKeypress(
(key) => {
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
void handleOpenEditor();
return true;
}
return false;
},
{ isActive: true, priority: true },
);

useEffect(() => {
if (planState.status !== PlanStatus.Loading) {
setShowLoading(false);
Expand Down Expand Up @@ -183,6 +219,8 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
);
}

const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR);

return (
<Box flexDirection="column" width={width}>
<AskUserDialog
Expand Down Expand Up @@ -220,6 +258,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
onCancel={onCancel}
width={width}
availableHeight={availableHeight}
extraParts={[`${editHint} to edit plan`]}
/>
</Box>
);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/components/ToolConfirmationQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { useUIActions } from '../contexts/UIActionsContext.js';

function getConfirmationHeader(
details: SerializableConfirmationDetails | undefined,
Expand All @@ -41,6 +42,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
confirmingTool,
}) => {
const config = useConfig();
const { getPreferredEditor } = useUIActions();
const isAlternateBuffer = useAlternateBuffer();
const {
mainAreaWidth,
Expand Down Expand Up @@ -134,6 +136,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
getPreferredEditor={getPreferredEditor}
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight}
isFocused={true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;

Expand All @@ -50,7 +50,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;

Expand Down Expand Up @@ -82,7 +82,7 @@ Implementation Steps
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;

Expand All @@ -109,7 +109,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;

Expand All @@ -136,7 +136,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;

Expand All @@ -163,7 +163,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;

Expand Down Expand Up @@ -216,7 +216,7 @@ Testing Strategy
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;

Expand All @@ -243,6 +243,6 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...

Enter to select · ↑/↓ to navigate · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc
│ Approves plan but requires confirmation for each tool │
│ 3. Type your feedback... │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel
│ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
Expand Down
Loading
Loading