From 5aebf18688d67659d48f50c925986677931cc6e9 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 11 Sep 2025 16:39:42 +0800 Subject: [PATCH] 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. --- packages/cli/src/ui/App.tsx | 76 +++++++++--- packages/cli/src/ui/components/AuthDialog.tsx | 2 +- packages/cli/src/ui/components/Help.tsx | 2 +- packages/cli/src/ui/hooks/useDialogClose.ts | 112 ++++++++++++++++++ 4 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useDialogClose.ts diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index e5dc3f91..67935188 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -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>, ) => { + // 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 ? ( - Press Ctrl+C again to exit. + Press Ctrl+C again to confirm exit. ) : ctrlDPressedOnce ? ( diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx index 0996302e..d5dd8146 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/components/AuthDialog.tsx @@ -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; } diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index d9f7b4a8..33120eb5 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -111,7 +111,7 @@ export const Help: React.FC = ({ commands }) => ( Ctrl+C {' '} - - Quit application + - Close dialogs, cancel requests, or quit application diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts new file mode 100644 index 00000000..fc842fb3 --- /dev/null +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -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; + 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 }; +}