feat(plan): support opening and modifying plan in external editor (#20348)

This commit is contained in:
Adib234
2026-02-25 23:38:44 -05:00
committed by GitHub
parent 39938000a9
commit ef247e220d
15 changed files with 297 additions and 47 deletions
+1
View File
@@ -607,6 +607,7 @@ const mockUIActions: UIActions = {
onHintSubmit: vi.fn(), onHintSubmit: vi.fn(),
handleRestart: vi.fn(), handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(), handleNewAgentsSelect: vi.fn(),
getPreferredEditor: vi.fn(),
}; };
let capturedOverflowState: OverflowState | undefined; let capturedOverflowState: OverflowState | undefined;
+2
View File
@@ -2554,6 +2554,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
} }
setNewAgents(null); setNewAgents(null);
}, },
getPreferredEditor,
}), }),
[ [
handleThemeSelect, handleThemeSelect,
@@ -2605,6 +2606,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
newAgents, newAgents,
config, config,
historyManager, historyManager,
getPreferredEditor,
], ],
); );
@@ -183,6 +183,10 @@ interface AskUserDialogProps {
* Height constraint for scrollable content. * Height constraint for scrollable content.
*/ */
availableHeight?: number; availableHeight?: number;
/**
* Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"])
*/
extraParts?: string[];
} }
interface ReviewViewProps { interface ReviewViewProps {
@@ -190,6 +194,7 @@ interface ReviewViewProps {
answers: { [key: string]: string }; answers: { [key: string]: string };
onSubmit: () => void; onSubmit: () => void;
progressHeader?: React.ReactNode; progressHeader?: React.ReactNode;
extraParts?: string[];
} }
const ReviewView: React.FC<ReviewViewProps> = ({ const ReviewView: React.FC<ReviewViewProps> = ({
@@ -197,6 +202,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
answers, answers,
onSubmit, onSubmit,
progressHeader, progressHeader,
extraParts,
}) => { }) => {
const unansweredCount = questions.length - Object.keys(answers).length; const unansweredCount = questions.length - Object.keys(answers).length;
const hasUnanswered = unansweredCount > 0; const hasUnanswered = unansweredCount > 0;
@@ -247,6 +253,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
<DialogFooter <DialogFooter
primaryAction="Enter to submit" primaryAction="Enter to submit"
navigationActions="Tab/Shift+Tab to edit answers" navigationActions="Tab/Shift+Tab to edit answers"
extraParts={extraParts}
/> />
</Box> </Box>
); );
@@ -925,6 +932,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onActiveTextInputChange, onActiveTextInputChange,
width, width,
availableHeight: availableHeightProp, availableHeight: availableHeightProp,
extraParts,
}) => { }) => {
const uiState = useContext(UIStateContext); const uiState = useContext(UIStateContext);
const availableHeight = const availableHeight =
@@ -1120,6 +1128,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
answers={answers} answers={answers}
onSubmit={handleReviewSubmit} onSubmit={handleReviewSubmit}
progressHeader={progressHeader} progressHeader={progressHeader}
extraParts={extraParts}
/> />
</Box> </Box>
); );
@@ -1143,6 +1152,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
? undefined ? undefined
: '↑/↓ to navigate' : '↑/↓ to navigate'
} }
extraParts={extraParts}
/> />
); );
@@ -11,6 +11,7 @@ import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js'; import { keyMatchers, Command } from '../keyMatchers.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import { import {
ApprovalMode, ApprovalMode,
validatePlanContent, validatePlanContent,
@@ -19,6 +20,10 @@ import {
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
vi.mock('../utils/editorUtils.js', () => ({
openFileInEditor: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>(); await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -144,6 +149,7 @@ Implement a comprehensive authentication system with multiple providers.
onApprove={onApprove} onApprove={onApprove}
onFeedback={onFeedback} onFeedback={onFeedback}
onCancel={onCancel} onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80} width={80}
availableHeight={24} availableHeight={24}
/>, />,
@@ -153,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers.
getTargetDir: () => mockTargetDir, getTargetDir: () => mockTargetDir,
getIdeMode: () => false, getIdeMode: () => false,
isTrustedFolder: () => true, isTrustedFolder: () => true,
getPreferredEditor: () => undefined,
storage: { storage: {
getPlansDir: () => mockPlansDir, getPlansDir: () => mockPlansDir,
}, },
@@ -418,6 +425,7 @@ Implement a comprehensive authentication system with multiple providers.
onApprove={onApprove} onApprove={onApprove}
onFeedback={onFeedback} onFeedback={onFeedback}
onCancel={onCancel} onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80} width={80}
availableHeight={24} availableHeight={24}
/> />
@@ -535,6 +543,40 @@ Implement a comprehensive authentication system with multiple providers.
}); });
expect(onFeedback).not.toHaveBeenCalled(); 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();
});
});
}, },
); );
}); });
@@ -5,25 +5,32 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Box, Text } from 'ink'; import { Box, Text, useStdin } from 'ink';
import { import {
ApprovalMode, ApprovalMode,
validatePlanPath, validatePlanPath,
validatePlanContent, validatePlanContent,
QuestionType, QuestionType,
type Config, type Config,
type EditorType,
processSingleFileContent, processSingleFileContent,
debugLogger,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { AskUserDialog } from './AskUserDialog.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 { export interface ExitPlanModeDialogProps {
planPath: string; planPath: string;
onApprove: (approvalMode: ApprovalMode) => void; onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void; onFeedback: (feedback: string) => void;
onCancel: () => void; onCancel: () => void;
getPreferredEditor: () => EditorType | undefined;
width: number; width: number;
availableHeight?: number; availableHeight?: number;
} }
@@ -38,6 +45,7 @@ interface PlanContentState {
status: PlanStatus; status: PlanStatus;
content?: string; content?: string;
error?: string; error?: string;
refresh: () => void;
} }
enum ApprovalOption { enum ApprovalOption {
@@ -53,10 +61,15 @@ const StatusMessage: React.FC<{
}> = ({ children }) => <Box paddingX={1}>{children}</Box>; }> = ({ children }) => <Box paddingX={1}>{children}</Box>;
function usePlanContent(planPath: string, config: Config): PlanContentState { 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, status: PlanStatus.Loading,
}); });
const refresh = useCallback(() => {
setVersion((v) => v + 1);
}, []);
useEffect(() => { useEffect(() => {
let ignore = false; let ignore = false;
setState({ status: PlanStatus.Loading }); setState({ status: PlanStatus.Loading });
@@ -120,9 +133,9 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
return () => { return () => {
ignore = true; ignore = true;
}; };
}, [planPath, config]); }, [planPath, config, version]);
return state; return { ...state, refresh };
} }
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
@@ -130,13 +143,36 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
onApprove, onApprove,
onFeedback, onFeedback,
onCancel, onCancel,
getPreferredEditor,
width, width,
availableHeight, availableHeight,
}) => { }) => {
const config = useConfig(); const config = useConfig();
const { stdin, setRawMode } = useStdin();
const planState = usePlanContent(planPath, config); const planState = usePlanContent(planPath, config);
const { refresh } = planState;
const [showLoading, setShowLoading] = useState(false); 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(() => { useEffect(() => {
if (planState.status !== PlanStatus.Loading) { if (planState.status !== PlanStatus.Loading) {
setShowLoading(false); setShowLoading(false);
@@ -183,6 +219,8 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
); );
} }
const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<AskUserDialog <AskUserDialog
@@ -220,6 +258,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
onCancel={onCancel} onCancel={onCancel}
width={width} width={width}
availableHeight={availableHeight} availableHeight={availableHeight}
extraParts={[`${editHint} to edit plan`]}
/> />
</Box> </Box>
); );
@@ -17,6 +17,7 @@ import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js'; import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { useUIActions } from '../contexts/UIActionsContext.js';
function getConfirmationHeader( function getConfirmationHeader(
details: SerializableConfirmationDetails | undefined, details: SerializableConfirmationDetails | undefined,
@@ -41,6 +42,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
confirmingTool, confirmingTool,
}) => { }) => {
const config = useConfig(); const config = useConfig();
const { getPreferredEditor } = useUIActions();
const isAlternateBuffer = useAlternateBuffer(); const isAlternateBuffer = useAlternateBuffer();
const { const {
mainAreaWidth, mainAreaWidth,
@@ -134,6 +136,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
callId={tool.callId} callId={tool.callId}
confirmationDetails={tool.confirmationDetails} confirmationDetails={tool.confirmationDetails}
config={config} config={config}
getPreferredEditor={getPreferredEditor}
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight} availableTerminalHeight={availableContentHeight}
isFocused={true} isFocused={true}
@@ -23,7 +23,7 @@ Files to Modify
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -50,7 +50,7 @@ Files to Modify
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -82,7 +82,7 @@ Implementation Steps
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -109,7 +109,7 @@ Files to Modify
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -136,7 +136,7 @@ Files to Modify
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -163,7 +163,7 @@ Files to Modify
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -216,7 +216,7 @@ Testing Strategy
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -243,6 +243,6 @@ Files to Modify
Approves plan but requires confirmation for each tool Approves plan but requires confirmation for each tool
3. Type your feedback... 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
" "
`; `;
@@ -85,7 +85,7 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc
│ Approves plan but requires confirmation for each tool │ │ Approves plan but requires confirmation for each tool │
│ 3. Type your feedback... │ │ 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
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
" "
`; `;
@@ -37,6 +37,7 @@ describe('ToolConfirmationMessage Redirection', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={100} terminalWidth={100}
/>, />,
@@ -52,6 +52,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -78,6 +79,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -101,6 +103,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -131,6 +134,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -161,6 +165,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -190,6 +195,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -219,6 +225,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -300,6 +307,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={details} confirmationDetails={details}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -321,6 +329,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={details} confirmationDetails={details}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -355,6 +364,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={editConfirmationDetails} confirmationDetails={editConfirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -381,6 +391,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={editConfirmationDetails} confirmationDetails={editConfirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -425,6 +436,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={editConfirmationDetails} confirmationDetails={editConfirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -452,6 +464,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={editConfirmationDetails} confirmationDetails={editConfirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -479,6 +492,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={editConfirmationDetails} confirmationDetails={editConfirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -505,6 +519,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -550,6 +565,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -581,6 +597,7 @@ describe('ToolConfirmationMessage', () => {
callId="test-call-id" callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30} availableTerminalHeight={30}
terminalWidth={80} terminalWidth={80}
/>, />,
@@ -14,6 +14,7 @@ import {
type Config, type Config,
type ToolConfirmationPayload, type ToolConfirmationPayload,
ToolConfirmationOutcome, ToolConfirmationOutcome,
type EditorType,
hasRedirection, hasRedirection,
debugLogger, debugLogger,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
@@ -49,6 +50,7 @@ export interface ToolConfirmationMessageProps {
callId: string; callId: string;
confirmationDetails: SerializableConfirmationDetails; confirmationDetails: SerializableConfirmationDetails;
config: Config; config: Config;
getPreferredEditor: () => EditorType | undefined;
isFocused?: boolean; isFocused?: boolean;
availableTerminalHeight?: number; availableTerminalHeight?: number;
terminalWidth: number; terminalWidth: number;
@@ -60,6 +62,7 @@ export const ToolConfirmationMessage: React.FC<
callId, callId,
confirmationDetails, confirmationDetails,
config, config,
getPreferredEditor,
isFocused = true, isFocused = true,
availableTerminalHeight, availableTerminalHeight,
terminalWidth, terminalWidth,
@@ -424,6 +427,7 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = ( bodyContent = (
<ExitPlanModeDialog <ExitPlanModeDialog
planPath={confirmationDetails.planPath} planPath={confirmationDetails.planPath}
getPreferredEditor={getPreferredEditor}
onApprove={(approvalMode) => { onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true, approved: true,
@@ -629,6 +633,7 @@ export const ToolConfirmationMessage: React.FC<
hasMcpToolDetails, hasMcpToolDetails,
mcpToolDetailsText, mcpToolDetailsText,
expandDetailsHintKey, expandDetailsHintKey,
getPreferredEditor,
]); ]);
const bodyOverflowDirection: 'top' | 'bottom' = const bodyOverflowDirection: 'top' | 'bottom' =
@@ -15,6 +15,8 @@ export interface DialogFooterProps {
navigationActions?: string; navigationActions?: string;
/** Exit shortcut (defaults to "Esc to cancel") */ /** Exit shortcut (defaults to "Esc to cancel") */
cancelAction?: string; cancelAction?: string;
/** Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */
extraParts?: string[];
} }
/** /**
@@ -25,11 +27,13 @@ export const DialogFooter: React.FC<DialogFooterProps> = ({
primaryAction, primaryAction,
navigationActions, navigationActions,
cancelAction = 'Esc to cancel', cancelAction = 'Esc to cancel',
extraParts = [],
}) => { }) => {
const parts = [primaryAction]; const parts = [primaryAction];
if (navigationActions) { if (navigationActions) {
parts.push(navigationActions); parts.push(navigationActions);
} }
parts.push(...extraParts);
parts.push(cancelAction); parts.push(cancelAction);
return ( return (
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import pathMod from 'node:path'; import pathMod from 'node:path';
@@ -13,12 +12,9 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import { LRUCache } from 'mnemonist'; import { LRUCache } from 'mnemonist';
import { import {
coreEvents, coreEvents,
CoreEvent,
debugLogger, debugLogger,
unescapePath, unescapePath,
type EditorType, type EditorType,
getEditorCommand,
isGuiEditor,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
toCodePoints, toCodePoints,
@@ -33,6 +29,7 @@ import { keyMatchers, Command } from '../../keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js'; import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
import { openFileInEditor } from '../../utils/editorUtils.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5; export const LARGE_PASTE_LINE_THRESHOLD = 5;
export const LARGE_PASTE_CHAR_THRESHOLD = 500; export const LARGE_PASTE_CHAR_THRESHOLD = 500;
@@ -3095,36 +3092,15 @@ export function useTextBuffer({
); );
fs.writeFileSync(filePath, expandedText, 'utf8'); fs.writeFileSync(filePath, expandedText, 'utf8');
let command: string | undefined = undefined;
const args = [filePath];
const preferredEditorType = getPreferredEditor?.();
if (!command && preferredEditorType) {
command = getEditorCommand(preferredEditorType);
if (isGuiEditor(preferredEditorType)) {
args.unshift('--wait');
}
}
if (!command) {
command =
process.env['VISUAL'] ??
process.env['EDITOR'] ??
(process.platform === 'win32' ? 'notepad' : 'vi');
}
dispatch({ type: 'create_undo_snapshot' }); dispatch({ type: 'create_undo_snapshot' });
const wasRaw = stdin?.isRaw ?? false;
try { try {
setRawMode?.(false); await openFileInEditor(
const { status, error } = spawnSync(command, args, { filePath,
stdio: 'inherit', stdin,
shell: process.platform === 'win32', setRawMode,
}); getPreferredEditor?.(),
if (error) throw error; );
if (typeof status === 'number' && status !== 0)
throw new Error(`External editor exited with status ${status}`);
let newText = fs.readFileSync(filePath, 'utf8'); let newText = fs.readFileSync(filePath, 'utf8');
newText = newText.replace(/\r\n?/g, '\n'); newText = newText.replace(/\r\n?/g, '\n');
@@ -3147,8 +3123,6 @@ export function useTextBuffer({
err, err,
); );
} finally { } finally {
coreEvents.emit(CoreEvent.ExternalEditorClosed);
if (wasRaw) setRawMode?.(true);
try { try {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} catch { } catch {
@@ -87,6 +87,7 @@ export interface UIActions {
onHintSubmit: (hint: string) => void; onHintSubmit: (hint: string) => void;
handleRestart: () => void; handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>; handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
getPreferredEditor: () => EditorType | undefined;
} }
export const UIActionsContext = createContext<UIActions | null>(null); export const UIActionsContext = createContext<UIActions | null>(null);
+151
View File
@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn, spawnSync } from 'node:child_process';
import type { ReadStream } from 'node:tty';
import {
coreEvents,
CoreEvent,
type EditorType,
getEditorCommand,
isGuiEditor,
isTerminalEditor,
} from '@google/gemini-cli-core';
/**
* Opens a file in an external editor and waits for it to close.
* Handles raw mode switching to ensure the editor can interact with the terminal.
*
* @param filePath Path to the file to open
* @param stdin The stdin stream from Ink/Node
* @param setRawMode Function to toggle raw mode
* @param preferredEditorType The user's preferred editor from config
*/
export async function openFileInEditor(
filePath: string,
stdin: ReadStream | null | undefined,
setRawMode: ((mode: boolean) => void) | undefined,
preferredEditorType?: EditorType,
): Promise<void> {
let command: string | undefined = undefined;
const args = [filePath];
if (preferredEditorType) {
command = getEditorCommand(preferredEditorType);
if (isGuiEditor(preferredEditorType)) {
args.unshift('--wait');
}
}
if (!command) {
command = process.env['VISUAL'] ?? process.env['EDITOR'];
if (command) {
const lowerCommand = command.toLowerCase();
const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) =>
lowerCommand.includes(gui),
);
if (
isGui &&
!lowerCommand.includes('--wait') &&
!lowerCommand.includes('-w')
) {
args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait');
}
}
}
if (!command) {
command = process.platform === 'win32' ? 'notepad' : 'vi';
}
const [executable = '', ...initialArgs] = command.split(' ');
// Determine if we should use sync or async based on the command/editor type.
// If we have a preferredEditorType, we can check if it's a terminal editor.
// Otherwise, we guess based on the command name.
const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano'];
const isTerminal = preferredEditorType
? isTerminalEditor(preferredEditorType)
: terminalEditors.some((te) => executable.toLowerCase().includes(te));
if (
isTerminal &&
(executable.includes('vi') ||
executable.includes('vim') ||
executable.includes('nvim'))
) {
// Pass -i NONE to prevent E138 'Can't write viminfo file' errors in restricted environments.
args.unshift('-i', 'NONE');
}
const wasRaw = stdin?.isRaw ?? false;
setRawMode?.(false);
try {
if (isTerminal) {
const result = spawnSync(executable, [...initialArgs, ...args], {
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.error) {
coreEvents.emitFeedback(
'error',
'[editorUtils] external terminal editor error',
result.error,
);
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
const err = new Error(
`External editor exited with status ${result.status}`,
);
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor error',
err,
);
throw err;
}
} else {
await new Promise<void>((resolve, reject) => {
const child = spawn(executable, [...initialArgs, ...args], {
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', (err) => {
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor spawn error',
err,
);
reject(err);
});
child.on('close', (status) => {
if (typeof status === 'number' && status !== 0) {
const err = new Error(
`External editor exited with status ${status}`,
);
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor error',
err,
);
reject(err);
} else {
resolve();
}
});
});
}
} finally {
if (wasRaw) {
setRawMode?.(true);
}
coreEvents.emit(CoreEvent.ExternalEditorClosed);
}
}