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:
fuyou
2025-09-19 14:52:29 +08:00
committed by GitHub
parent 938c850ed8
commit e48f61bdc7
4 changed files with 231 additions and 25 deletions

View File

@@ -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.');
});
});

View File

@@ -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>

View File

@@ -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();
});
});
});

View File

@@ -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,