mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 17:57:46 +00:00
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:
@@ -25,6 +25,7 @@ import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.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 { 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
|
||||
const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
|
||||
|
||||
@@ -626,6 +626,25 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
handleWelcomeBackClose,
|
||||
} = 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
|
||||
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
|
||||
useMessageQueue({
|
||||
@@ -697,23 +716,52 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
setPressedOnce: (value: boolean) => void,
|
||||
timerRef: ReturnType<typeof useRef<NodeJS.Timeout | null>>,
|
||||
) => {
|
||||
// Fast double-press: Direct quit (preserve user habit)
|
||||
if (pressedOnce) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
// Directly invoke the central command handler.
|
||||
// Exit directly without showing confirmation dialog
|
||||
handleSlashCommand('/quit');
|
||||
} else {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -746,9 +794,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
if (isAuthenticating) {
|
||||
return;
|
||||
}
|
||||
if (!ctrlCPressedOnce) {
|
||||
cancelOngoingRequest?.();
|
||||
}
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
@@ -780,7 +825,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
ctrlDTimerRef,
|
||||
handleSlashCommand,
|
||||
isAuthenticating,
|
||||
cancelOngoingRequest,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1247,7 +1291,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
)}
|
||||
{ctrlCPressedOnce ? (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
Press Ctrl+C again to exit.
|
||||
Press Ctrl+C again to confirm exit.
|
||||
</Text>
|
||||
) : ctrlDPressedOnce ? (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
|
||||
@@ -123,7 +123,7 @@ export function AuthDialog({
|
||||
if (settings.merged.selectedAuthType === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Ctrl+C
|
||||
</Text>{' '}
|
||||
- Quit application
|
||||
- Close dialogs, cancel requests, or quit application
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
|
||||
112
packages/cli/src/ui/hooks/useDialogClose.ts
Normal file
112
packages/cli/src/ui/hooks/useDialogClose.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user