mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(plan): support opening and modifying plan in external editor (#20348)
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user