This commit is contained in:
A.K.M. Adib
2026-02-24 14:34:11 -05:00
parent aa9163da60
commit 0a48828942
14 changed files with 460 additions and 39 deletions
+4
View File
@@ -94,6 +94,7 @@ export enum Command {
EXPAND_PASTE = 'app.expandPaste',
FOCUS_SHELL_INPUT = 'app.focusShellInput',
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
OPEN_PLAN_IN_EDITOR = 'plan.openInEditor',
CLEAR_SCREEN = 'app.clearScreen',
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
@@ -294,6 +295,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.OPEN_PLAN_IN_EDITOR]: [{ key: 'x', ctrl: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
@@ -414,6 +416,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.OPEN_PLAN_IN_EDITOR,
Command.CLEAR_SCREEN,
Command.RESTART_APP,
Command.SUSPEND_APP,
@@ -522,6 +525,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Show warning when trying to move focus away from shell input.',
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.OPEN_PLAN_IN_EDITOR]: 'Open the current plan in an external editor.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
@@ -183,6 +183,10 @@ interface AskUserDialogProps {
* Height constraint for scrollable content.
*/
availableHeight?: number;
/**
* Additional shortcuts to display in the footer.
*/
extraFooterActions?: string[];
}
interface ReviewViewProps {
@@ -925,6 +929,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onActiveTextInputChange,
width,
availableHeight: availableHeightProp,
extraFooterActions,
}) => {
const uiState = useContext(UIStateContext);
const availableHeight =
@@ -1143,6 +1148,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
? undefined
: '↑/↓ to navigate'
}
extraActions={extraFooterActions}
/>
);
@@ -16,6 +16,7 @@ import {
validatePlanContent,
processSingleFileContent,
type FileSystemService,
openFileInEditor,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
@@ -27,6 +28,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
validatePlanPath: vi.fn(async () => null),
validatePlanContent: vi.fn(async () => null),
processSingleFileContent: vi.fn(),
openFileInEditor: vi.fn(async () => ({ modified: true })),
IdeClient: {
getInstance: vi.fn(async () => ({
isDiffingEnabled: vi.fn(() => false),
openDiff: vi.fn(),
})),
},
};
});
@@ -204,7 +212,7 @@ Implement a comprehensive authentication system with multiple providers.
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT, false);
});
});
@@ -223,7 +231,7 @@ Implement a comprehensive authentication system with multiple providers.
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT, false);
});
});
@@ -254,7 +262,7 @@ Implement a comprehensive authentication system with multiple providers.
writeKey(stdin, '\r');
await waitFor(() => {
expect(onFeedback).toHaveBeenCalledWith('Add tests');
expect(onFeedback).toHaveBeenCalledWith('Add tests', false);
});
});
@@ -350,7 +358,7 @@ Implement a comprehensive authentication system with multiple providers.
writeKey(stdin, '2');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT, false);
});
});
@@ -531,10 +539,50 @@ Implement a comprehensive authentication system with multiple providers.
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT, false);
});
expect(onFeedback).not.toHaveBeenCalled();
});
it('opens editor when Ctrl+X is pressed and reloads plan', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
const updatedPlanContent = 'Updated plan content';
vi.mocked(processSingleFileContent).mockResolvedValue({
llmContent: updatedPlanContent,
returnDisplay: 'Read file',
});
writeKey(stdin, '\x18'); // Ctrl+X
await waitFor(() => {
expect(openFileInEditor).toHaveBeenCalled();
});
// Advance timers for reload
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain(updatedPlanContent);
});
// Submit to verify planModified is true
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT, true);
});
});
},
);
});
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
ApprovalMode,
@@ -14,15 +14,24 @@ import {
QuestionType,
type Config,
processSingleFileContent,
coreEvents,
openFileInEditor,
IdeClient,
debugLogger,
isValidEditorType,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { AskUserDialog } from './AskUserDialog.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { KeypressPriority } from '../contexts/KeypressContext.js';
import { Command, keyMatchers } from '../keyMatchers.js';
export interface ExitPlanModeDialogProps {
planPath: string;
onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void;
onApprove: (approvalMode: ApprovalMode, planModified: boolean) => void;
onFeedback: (feedback: string, planModified: boolean) => void;
onCancel: () => void;
width: number;
availableHeight?: number;
@@ -38,6 +47,7 @@ interface PlanContentState {
status: PlanStatus;
content?: string;
error?: string;
reload: () => void;
}
enum ApprovalOption {
@@ -53,10 +63,15 @@ const StatusMessage: React.FC<{
}> = ({ children }) => <Box paddingX={1}>{children}</Box>;
function usePlanContent(planPath: string, config: Config): PlanContentState {
const [state, setState] = useState<PlanContentState>({
const [nonce, setNonce] = useState(0);
const [state, setState] = useState<Omit<PlanContentState, 'reload'>>({
status: PlanStatus.Loading,
});
const reload = useCallback(() => {
setNonce((n) => n + 1);
}, []);
useEffect(() => {
let ignore = false;
setState({ status: PlanStatus.Loading });
@@ -120,9 +135,9 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
return () => {
ignore = true;
};
}, [planPath, config]);
}, [planPath, config, nonce]);
return state;
return useMemo(() => ({ ...state, reload }), [state, reload]);
}
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
@@ -134,9 +149,11 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
availableHeight,
}) => {
const config = useConfig();
const settings = useSettings();
const planState = usePlanContent(planPath, config);
const [showLoading, setShowLoading] = useState(false);
const [isModified, setIsModified] = useState(false);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
if (planState.status !== PlanStatus.Loading) {
setShowLoading(false);
@@ -150,6 +167,80 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
return () => clearTimeout(timer);
}, [planState.status]);
const performOpenInEditor = useCallback(async () => {
if (isEditing) return;
setIsEditing(true);
try {
const ideClient = await IdeClient.getInstance();
const preferredEditorRaw = settings.merged.general.preferredEditor;
const preferredEditor =
typeof preferredEditorRaw === 'string' &&
isValidEditorType(preferredEditorRaw)
? preferredEditorRaw
: undefined;
const result = await openFileInEditor(planPath, {
preferredEditor,
ideClient,
readTextFile: (path: string) =>
config.getFileSystemService().readTextFile(path),
writeTextFile: (path, content) =>
config.getFileSystemService().writeTextFile(path, content),
});
if (result.modified) {
setIsModified(true);
planState.reload();
}
} catch (err) {
coreEvents.emitFeedback(
'error',
'[ExitPlanModeDialog] external editor error',
err,
);
} finally {
setIsEditing(false);
}
}, [
isEditing,
settings.merged.general.preferredEditor,
planPath,
config,
planState,
]);
const handleOpenInEditor = useCallback(() => {
void performOpenInEditor();
}, [performOpenInEditor]);
const syncIde = useCallback(
async (outcome: 'accepted' | 'rejected') => {
try {
const ideClient = await IdeClient.getInstance();
if (ideClient.isDiffingEnabled()) {
await ideClient.resolveDiffFromCli(planPath, outcome);
}
} catch (err) {
debugLogger.error('ExitPlanModeDialog: IDE sync failed:', err);
}
},
[planPath],
);
useKeypress(
(key) => {
if (keyMatchers[Command.OPEN_PLAN_IN_EDITOR](key)) {
handleOpenInEditor();
return true;
}
return false;
},
{
isActive: planState.status === PlanStatus.Loaded && !isEditing,
priority: KeypressPriority.Critical,
},
);
if (planState.status === PlanStatus.Loading) {
if (!showLoading) {
return null;
@@ -209,17 +300,24 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
]}
onSubmit={(answers) => {
const answer = answers['0'];
// Sync IDE state first
void syncIde('accepted');
if (answer === ApprovalOption.Auto) {
onApprove(ApprovalMode.AUTO_EDIT);
onApprove(ApprovalMode.AUTO_EDIT, isModified);
} else if (answer === ApprovalOption.Manual) {
onApprove(ApprovalMode.DEFAULT);
onApprove(ApprovalMode.DEFAULT, isModified);
} else if (answer) {
onFeedback(answer);
onFeedback(answer, isModified);
}
}}
onCancel={onCancel}
onCancel={() => {
void syncIde('rejected');
onCancel();
}}
width={width}
availableHeight={availableHeight}
extraFooterActions={['Ctrl+X to open in editor']}
/>
</Box>
);
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
"
`;
@@ -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 open in editor · Esc to cancel
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -340,16 +340,18 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
onApprove={(approvalMode) => {
onApprove={(approvalMode, planModified) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode,
planModified,
});
}}
onFeedback={(feedback) => {
onFeedback={(feedback, planModified) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback,
planModified,
});
}}
onCancel={() => {
@@ -15,6 +15,8 @@ export interface DialogFooterProps {
navigationActions?: string;
/** Exit shortcut (defaults to "Esc to cancel") */
cancelAction?: string;
/** Additional shortcuts to display */
extraActions?: string[];
}
/**
@@ -25,11 +27,15 @@ export const DialogFooter: React.FC<DialogFooterProps> = ({
primaryAction,
navigationActions,
cancelAction = 'Esc to cancel',
extraActions,
}) => {
const parts = [primaryAction];
if (navigationActions) {
parts.push(navigationActions);
}
if (extraActions) {
parts.push(...extraActions);
}
parts.push(cancelAction);
return (