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 { 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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
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