From e48f61bdc738a1e198e754197de2fcde5ddb7455 Mon Sep 17 00:00:00 2001 From: fuyou Date: Fri, 19 Sep 2025 14:52:29 +0800 Subject: [PATCH] fix(cli): ctrl c/ctrl d close cli when in dialogs (#8685) Co-authored-by: Jacob Richman Co-authored-by: matt korwel --- packages/cli/src/ui/App.test.tsx | 32 +++++ packages/cli/src/ui/App.tsx | 19 ++- packages/cli/src/ui/AppContainer.test.tsx | 160 ++++++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 45 +++--- 4 files changed, 231 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 1f4d803b3a..4b848c41a4 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + expect(lastFrame()).toContain('Press Ctrl+D again to exit.'); + }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 65ffd9f5bc..34d9bd7e54 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -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 = () => { {uiState.dialogsVisible ? : } + + {uiState.dialogsVisible && uiState.ctrlCPressedOnce && ( + + + Press Ctrl+C again to exit. + + + )} + + {uiState.dialogsVisible && uiState.ctrlDPressedOnce && ( + + + Press Ctrl+D again to exit. + + + )} diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 6548542bda..cdca192ae4 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); + + vi.advanceTimersByTime(1001); + + expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); + + vi.useRealTimers(); + }); + }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index cca13d5dfe..5d0e13eb14 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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,