feat: enhance Ctrl+C behavior with intelligent dialog/request handling

- Preserve fast double-press habit: Ctrl+C twice = direct quit
- Implement smart cleanup on single press with priority order:
  1. Close dialogs (theme, auth, settings, etc.) like ESC key
  2. Cancel ongoing streaming requests
  3. Clear input buffer content
  4. Show quit confirmation when all cleanup done
- Special case: Ctrl+C in quit-confirm dialog = immediate exit
- Extract dialog closing logic into reusable useDialogClose hook
- Refactor handleExit to centralize all exit logic
- Update help text and dialog hints to reflect new behavior
- Fix delay issues in first Ctrl+C press by optimizing request cancellation

Improves UX by making Ctrl+C context-aware while maintaining user habits.
This commit is contained in:
pomelo-nwu
2025-09-11 16:39:42 +08:00
parent 8762038b0e
commit 5aebf18688
4 changed files with 174 additions and 18 deletions

View File

@@ -25,6 +25,7 @@ import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMessageQueue } from './hooks/useMessageQueue.js';
@@ -108,7 +109,6 @@ import { appEvents, AppEvent } from '../utils/events.js';
import { isNarrowWidth } from './utils/isNarrowWidth.js'; import { isNarrowWidth } from './utils/isNarrowWidth.js';
import { WelcomeBackDialog } from './components/WelcomeBackDialog.js'; import { WelcomeBackDialog } from './components/WelcomeBackDialog.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
// Maximum number of queued messages to display in UI to prevent performance issues // Maximum number of queued messages to display in UI to prevent performance issues
const MAX_DISPLAYED_QUEUED_MESSAGES = 3; const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
@@ -626,6 +626,25 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
handleWelcomeBackClose, handleWelcomeBackClose,
} = useWelcomeBack(config, submitQuery, buffer, settings.merged); } = useWelcomeBack(config, submitQuery, buffer, settings.merged);
// Dialog close functionality
const { closeAnyOpenDialog } = useDialogClose({
isThemeDialogOpen,
handleThemeSelect,
isAuthDialogOpen,
handleAuthSelect,
selectedAuthType: settings.merged.selectedAuthType,
isEditorDialogOpen,
exitEditorDialog,
isSettingsDialogOpen,
closeSettingsDialog,
isFolderTrustDialogOpen,
showPrivacyNotice,
setShowPrivacyNotice,
showWelcomeBackDialog,
handleWelcomeBackClose,
quitConfirmationRequest,
});
// Message queue for handling input during streaming // Message queue for handling input during streaming
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
useMessageQueue({ useMessageQueue({
@@ -697,23 +716,52 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
setPressedOnce: (value: boolean) => void, setPressedOnce: (value: boolean) => void,
timerRef: ReturnType<typeof useRef<NodeJS.Timeout | null>>, timerRef: ReturnType<typeof useRef<NodeJS.Timeout | null>>,
) => { ) => {
// Fast double-press: Direct quit (preserve user habit)
if (pressedOnce) { if (pressedOnce) {
if (timerRef.current) { if (timerRef.current) {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
} }
// Directly invoke the central command handler. // Exit directly without showing confirmation dialog
handleSlashCommand('/quit'); handleSlashCommand('/quit');
} else { return;
setPressedOnce(true);
// Show quit confirmation dialog immediately on first Ctrl+C
handleSlashCommand('/quit-confirm');
timerRef.current = setTimeout(() => {
setPressedOnce(false);
timerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS);
} }
// First press: Prioritize cleanup tasks
// Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately"
if (quitConfirmationRequest) {
handleSlashCommand('/quit');
return;
}
// 1. Close other dialogs (highest priority)
if (closeAnyOpenDialog()) {
return; // Dialog closed, end processing
}
// 2. Cancel ongoing requests
if (streamingState === StreamingState.Responding) {
cancelOngoingRequest?.();
return; // Request cancelled, end processing
}
// 3. Clear input buffer (if has content)
if (buffer.text.length > 0) {
buffer.setText('');
return; // Input cleared, end processing
}
// All cleanup tasks completed, show quit confirmation dialog
handleSlashCommand('/quit-confirm');
}, },
[handleSlashCommand], [
handleSlashCommand,
quitConfirmationRequest,
closeAnyOpenDialog,
streamingState,
cancelOngoingRequest,
buffer,
],
); );
const handleGlobalKeypress = useCallback( const handleGlobalKeypress = useCallback(
@@ -746,9 +794,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (isAuthenticating) { if (isAuthenticating) {
return; return;
} }
if (!ctrlCPressedOnce) {
cancelOngoingRequest?.();
}
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
} else if (keyMatchers[Command.EXIT](key)) { } else if (keyMatchers[Command.EXIT](key)) {
if (buffer.text.length > 0) { if (buffer.text.length > 0) {
@@ -780,7 +825,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
ctrlDTimerRef, ctrlDTimerRef,
handleSlashCommand, handleSlashCommand,
isAuthenticating, isAuthenticating,
cancelOngoingRequest,
], ],
); );
@@ -1247,7 +1291,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
)} )}
{ctrlCPressedOnce ? ( {ctrlCPressedOnce ? (
<Text color={Colors.AccentYellow}> <Text color={Colors.AccentYellow}>
Press Ctrl+C again to exit. Press Ctrl+C again to confirm exit.
</Text> </Text>
) : ctrlDPressedOnce ? ( ) : ctrlDPressedOnce ? (
<Text color={Colors.AccentYellow}> <Text color={Colors.AccentYellow}>

View File

@@ -123,7 +123,7 @@ export function AuthDialog({
if (settings.merged.selectedAuthType === undefined) { if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set // Prevent exiting if no auth method is set
setErrorMessage( setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.', 'You must select an auth method to proceed. Press Ctrl+C again to exit.',
); );
return; return;
} }

View File

@@ -111,7 +111,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text bold color={Colors.AccentPurple}> <Text bold color={Colors.AccentPurple}>
Ctrl+C Ctrl+C
</Text>{' '} </Text>{' '}
- Quit application - Close dialogs, cancel requests, or quit application
</Text> </Text>
<Text color={Colors.Foreground}> <Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}> <Text bold color={Colors.AccentPurple}>

View File

@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
export interface DialogCloseOptions {
// Theme dialog
isThemeDialogOpen: boolean;
handleThemeSelect: (theme: string | undefined, scope: SettingScope) => void;
// Auth dialog
isAuthDialogOpen: boolean;
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
) => Promise<void>;
selectedAuthType: AuthType | undefined;
// Editor dialog
isEditorDialogOpen: boolean;
exitEditorDialog: () => void;
// Settings dialog
isSettingsDialogOpen: boolean;
closeSettingsDialog: () => void;
// Folder trust dialog
isFolderTrustDialogOpen: boolean;
// Privacy notice
showPrivacyNotice: boolean;
setShowPrivacyNotice: (show: boolean) => void;
// Welcome back dialog
showWelcomeBackDialog: boolean;
handleWelcomeBackClose: () => void;
// Quit confirmation dialog
quitConfirmationRequest: {
onConfirm: (shouldQuit: boolean, action?: string) => void;
} | null;
}
/**
* Hook that handles closing dialogs when Ctrl+C is pressed.
* This mimics the ESC key behavior by calling the same handlers that ESC uses.
* Returns true if a dialog was closed, false if no dialogs were open.
*/
export function useDialogClose(options: DialogCloseOptions) {
const closeAnyOpenDialog = useCallback((): boolean => {
// Check each dialog in priority order and close using the same logic as ESC key
if (options.isThemeDialogOpen) {
// Mimic ESC behavior: onSelect(undefined, selectedScope) - keeps current theme
options.handleThemeSelect(undefined, SettingScope.User);
return true;
}
if (options.isAuthDialogOpen) {
// Mimic ESC behavior: only close if already authenticated (same as AuthDialog ESC logic)
if (options.selectedAuthType !== undefined) {
// Note: We don't await this since we want non-blocking behavior like ESC
void options.handleAuthSelect(undefined, SettingScope.User);
}
// Note: AuthDialog prevents ESC exit if not authenticated, we follow same logic
return true;
}
if (options.isEditorDialogOpen) {
// Mimic ESC behavior: call onExit() directly
options.exitEditorDialog();
return true;
}
if (options.isSettingsDialogOpen) {
// Mimic ESC behavior: onSelect(undefined, selectedScope)
options.closeSettingsDialog();
return true;
}
if (options.isFolderTrustDialogOpen) {
// FolderTrustDialog doesn't expose close function, but ESC would prevent exit
// We follow the same pattern - prevent exit behavior
return true;
}
if (options.showPrivacyNotice) {
// PrivacyNotice uses onExit callback
options.setShowPrivacyNotice(false);
return true;
}
if (options.showWelcomeBackDialog) {
// WelcomeBack has its own close handler
options.handleWelcomeBackClose();
return true;
}
// Note: quitConfirmationRequest is NOT handled here anymore
// It's handled specially in handleExit - ctrl+c in quit-confirm should exit immediately
// No dialog was open
return false;
}, [options]);
return { closeAnyOpenDialog };
}