mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
fix(cli): ctrl c/ctrl d close cli when in dialogs (#8685)
Co-authored-by: Jacob Richman <jacob314@gmail.com> Co-authored-by: matt korwel <matt.korwel@gmail.com>
This commit is contained in:
@@ -83,4 +83,36 @@ describe('App', () => {
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('DialogManager');
|
||||
});
|
||||
|
||||
it('should show Ctrl+C exit prompt when dialogs are visible and ctrlCPressedOnce is true', () => {
|
||||
const ctrlCUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
ctrlCPressedOnce: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={ctrlCUIState}>
|
||||
<App />
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
|
||||
});
|
||||
|
||||
it('should show Ctrl+D exit prompt when dialogs are visible and ctrlDPressedOnce is true', () => {
|
||||
const ctrlDUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
ctrlDPressedOnce: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={ctrlDUIState}>
|
||||
<App />
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import { Notifications } from './components/Notifications.js';
|
||||
import { MainContent } from './components/MainContent.js';
|
||||
@@ -12,6 +12,7 @@ import { DialogManager } from './components/DialogManager.js';
|
||||
import { Composer } from './components/Composer.js';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { QuittingDisplay } from './components/QuittingDisplay.js';
|
||||
import { theme } from './semantic-colors.js';
|
||||
|
||||
export const App = () => {
|
||||
const uiState = useUIState();
|
||||
@@ -29,6 +30,22 @@ export const App = () => {
|
||||
<Notifications />
|
||||
|
||||
{uiState.dialogsVisible ? <DialogManager /> : <Composer />}
|
||||
|
||||
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+C again to exit.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+D again to exit.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</StreamingContext.Provider>
|
||||
|
||||
@@ -605,4 +605,164 @@ describe('AppContainer State Management', () => {
|
||||
expect(lastCall[2]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling', () => {
|
||||
it('should block quit command during authentication', () => {
|
||||
mockedUseAuthCommand.mockReturnValue({
|
||||
authState: 'unauthenticated',
|
||||
setAuthState: vi.fn(),
|
||||
authError: null,
|
||||
onAuthError: vi.fn(),
|
||||
});
|
||||
|
||||
const mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
slashCommands: [],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
|
||||
});
|
||||
|
||||
it('should prevent exit command when text buffer has content', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some user input',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
|
||||
const mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
slashCommands: [],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
|
||||
});
|
||||
|
||||
it('should require double Ctrl+C to exit when dialogs are open', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockedUseThemeCommand.mockReturnValue({
|
||||
isThemeDialogOpen: true,
|
||||
openThemeDialog: vi.fn(),
|
||||
handleThemeSelect: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
});
|
||||
|
||||
const mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
slashCommands: [],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should cancel ongoing request on first Ctrl+C', () => {
|
||||
const mockCancelOngoingRequest = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||
});
|
||||
|
||||
const mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
slashCommands: [],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
|
||||
});
|
||||
|
||||
it('should reset Ctrl+C state after timeout', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: mockHandleSlashCommand,
|
||||
slashCommands: [],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -865,14 +865,27 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
console.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
}
|
||||
|
||||
const anyDialogOpen =
|
||||
isThemeDialogOpen ||
|
||||
isAuthDialogOpen ||
|
||||
isEditorDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isFolderTrustDialogOpen ||
|
||||
showPrivacyNotice;
|
||||
if (anyDialogOpen) {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
if (!ctrlCPressedOnce) {
|
||||
cancelOngoingRequest?.();
|
||||
}
|
||||
|
||||
if (!ctrlCPressedOnce) {
|
||||
setCtrlCPressedOnce(true);
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressedOnce(false);
|
||||
ctrlCTimerRef.current = null;
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
return;
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return;
|
||||
}
|
||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -898,16 +911,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
ideContextState
|
||||
) {
|
||||
handleSlashCommand('/ide status');
|
||||
} else if (keyMatchers[Command.QUIT](key)) {
|
||||
if (!ctrlCPressedOnce) {
|
||||
cancelOngoingRequest?.();
|
||||
}
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return;
|
||||
}
|
||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
} else if (
|
||||
keyMatchers[Command.SHOW_MORE_LINES](key) &&
|
||||
!enteringConstrainHeightMode
|
||||
@@ -937,12 +940,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
ctrlDTimerRef,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
isThemeDialogOpen,
|
||||
isAuthDialogOpen,
|
||||
isEditorDialogOpen,
|
||||
isSettingsDialogOpen,
|
||||
isFolderTrustDialogOpen,
|
||||
showPrivacyNotice,
|
||||
activePtyId,
|
||||
shellFocused,
|
||||
settings.merged.general?.debugKeystrokeLogging,
|
||||
|
||||
Reference in New Issue
Block a user