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,