diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 7304d912..7b955cdf 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -25,7 +25,7 @@ import { initCommand } from '../ui/commands/initCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; -import { quitCommand } from '../ui/commands/quitCommand.js'; +import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; @@ -70,6 +70,7 @@ export class BuiltinCommandLoader implements ICommandLoader { memoryCommand, privacyCommand, quitCommand, + quitConfirmCommand, restoreCommand(this.config), statsCommand, themeCommand, diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 11536b75..7e65aff0 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -23,6 +23,8 @@ import { useAuthCommand } from './hooks/useAuthCommand.js'; import { useQwenAuth } from './hooks/useQwenAuth.js'; 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 { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; @@ -40,6 +42,7 @@ import { QwenOAuthProgress } from './components/QwenOAuthProgress.js'; import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { FolderTrustDialog } from './components/FolderTrustDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; +import { QuitConfirmationDialog } from './components/QuitConfirmationDialog.js'; import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js'; import { Colors } from './colors.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; @@ -103,6 +106,7 @@ import { SettingsDialog } from './components/SettingsDialog.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; 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 @@ -274,6 +278,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { setIsTrustedFolder, ); + const { showQuitConfirmation, handleQuitConfirmationSelect } = + useQuitConfirmation(); + const { isAuthDialogOpen, openAuthDialog, @@ -550,6 +557,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { commandContext, shellConfirmationRequest, confirmationRequest, + quitConfirmationRequest, } = useSlashCommandProcessor( config, settings, @@ -568,6 +576,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { toggleVimEnabled, setIsProcessing, setGeminiMdFileCount, + showQuitConfirmation, ); const buffer = useTextBuffer({ @@ -608,6 +617,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { () => cancelHandlerRef.current(), ); + // Welcome back functionality + const { + welcomeBackInfo, + showWelcomeBackDialog, + welcomeBackChoice, + handleWelcomeBackSelection, + handleWelcomeBackClose, + } = useWelcomeBack(config, submitQuery); + // Message queue for handling input during streaming const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = useMessageQueue({ @@ -687,6 +705,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { 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; @@ -816,7 +836,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding) && !initError && - !isProcessing; + !isProcessing && + !showWelcomeBackDialog; const handleClearScreen = useCallback(() => { clearItems(); @@ -895,6 +916,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { !isThemeDialogOpen && !isEditorDialogOpen && !showPrivacyNotice && + !showWelcomeBackDialog && + welcomeBackChoice !== 'restart' && geminiClient?.isInitialized?.() ) { submitQuery(initialPrompt); @@ -908,6 +931,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { isThemeDialogOpen, isEditorDialogOpen, showPrivacyNotice, + showWelcomeBackDialog, + welcomeBackChoice, geminiClient, ]); @@ -1016,6 +1041,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ))} )} + {showWelcomeBackDialog && welcomeBackInfo?.hasHistory && ( + + )} {shouldShowIdePrompt && currentIDE ? ( { /> ) : isFolderTrustDialogOpen ? ( + ) : quitConfirmationRequest ? ( + { + const result = handleQuitConfirmationSelect(choice); + if (result?.shouldQuit) { + quitConfirmationRequest.onConfirm(true, result.action); + } else { + quitConfirmationRequest.onConfirm(false); + } + }} + /> ) : shellConfirmationRequest ? ( ) : confirmationRequest ? ( diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index 36f15c71..3e175d9c 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -7,6 +7,33 @@ import { formatDuration } from '../utils/formatters.js'; import { CommandKind, type SlashCommand } from './types.js'; +export const quitConfirmCommand: SlashCommand = { + name: 'quit-confirm', + description: 'Show quit confirmation dialog', + kind: CommandKind.BUILT_IN, + action: (context) => { + const now = Date.now(); + const { sessionStartTime } = context.session.stats; + const wallDuration = now - sessionStartTime.getTime(); + + return { + type: 'quit_confirmation', + messages: [ + { + type: 'user', + text: `/quit-confirm`, + id: now - 1, + }, + { + type: 'quit_confirmation', + duration: formatDuration(wallDuration), + id: now, + }, + ], + }; + }, +}; + export const quitCommand: SlashCommand = { name: 'quit', altNames: ['exit'], diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index bf0457be..a8c3ac75 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -88,6 +88,12 @@ export interface QuitActionReturn { messages: HistoryItem[]; } +/** The return type for a command action that requests quit confirmation. */ +export interface QuitConfirmationActionReturn { + type: 'quit_confirmation'; + messages: HistoryItem[]; +} + /** * The return type for a command action that results in a simple message * being displayed to the user. @@ -154,6 +160,7 @@ export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn | QuitActionReturn + | QuitConfirmationActionReturn | OpenDialogActionReturn | LoadHistoryActionReturn | SubmitPromptActionReturn diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 170a5ee6..627a04f7 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -81,6 +81,9 @@ export const HistoryItemDisplay: React.FC = ({ {item.type === 'model_stats' && } {item.type === 'tool_stats' && } {item.type === 'quit' && } + {item.type === 'quit_confirmation' && ( + + )} {item.type === 'tool_group' && ( void; +} + +export const QuitConfirmationDialog: React.FC = ({ + onSelect, +}) => { + useKeypress( + (key) => { + if (key.name === 'escape') { + onSelect(QuitChoice.QUIT); + } + }, + { isActive: true }, + ); + + const options: Array> = [ + { + label: 'Quit immediately (/quit)', + value: QuitChoice.QUIT, + }, + { + label: 'Generate summary and quit (/chat summary)', + value: QuitChoice.SUMMARY_AND_QUIT, + }, + { + label: 'Save conversation and quit (/chat save)', + value: QuitChoice.SAVE_AND_QUIT, + }, + ]; + + return ( + + + What would you like to do before exiting? + + + + + ); +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 014bba61..6eb06960 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -18,6 +18,7 @@ import { ToolConfirmationOutcome, } from '@qwen-code/qwen-code-core'; import { useSessionStats } from '../contexts/SessionContext.js'; +import { formatDuration } from '../utils/formatters.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { Message, @@ -54,6 +55,7 @@ export const useSlashCommandProcessor = ( toggleVimEnabled: () => Promise, setIsProcessing: (isProcessing: boolean) => void, setGeminiMdFileCount: (count: number) => void, + _showQuitConfirmation: () => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); @@ -74,6 +76,10 @@ export const useSlashCommandProcessor = ( prompt: React.ReactNode; onConfirm: (confirmed: boolean) => void; }>(null); + const [quitConfirmationRequest, setQuitConfirmationRequest] = + useState void; + }>(null); const [sessionShellAllowlist, setSessionShellAllowlist] = useState( new Set(), @@ -141,6 +147,11 @@ export const useSlashCommandProcessor = ( type: 'quit', duration: message.duration, }; + } else if (message.type === MessageType.QUIT_CONFIRMATION) { + historyItemContent = { + type: 'quit_confirmation', + duration: message.duration, + }; } else if (message.type === MessageType.COMPRESSION) { historyItemContent = { type: 'compression', @@ -398,6 +409,70 @@ export const useSlashCommandProcessor = ( }); return { type: 'handled' }; } + case 'quit_confirmation': + // Show quit confirmation dialog instead of immediately quitting + setQuitConfirmationRequest({ + onConfirm: (shouldQuit: boolean, action?: string) => { + setQuitConfirmationRequest(null); + if (shouldQuit) { + if (action === 'save_and_quit') { + // First save conversation with auto-generated tag, then quit + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, '-'); + const autoSaveTag = `auto-save chat ${timestamp}`; + handleSlashCommand(`/chat save "${autoSaveTag}"`); + setTimeout(() => handleSlashCommand('/quit'), 100); + } else if (action === 'summary_and_quit') { + // Generate summary and then quit + handleSlashCommand('/chat summary') + .then(() => { + // Wait for summary to complete, then quit + handleSlashCommand('/quit'); + }) + .catch((error) => { + // If summary fails, still quit but show error + addItem( + { + type: 'error', + text: `Failed to generate summary before quit: ${ + error instanceof Error + ? error.message + : String(error) + }`, + }, + Date.now(), + ); + handleSlashCommand('/quit'); + }); + } else { + // Just quit immediately - trigger the actual quit action + const now = Date.now(); + const { sessionStartTime } = session.stats; + const wallDuration = now - sessionStartTime.getTime(); + + setQuittingMessages([ + { + type: 'user', + text: `/quit`, + id: now - 1, + }, + { + type: 'quit', + duration: formatDuration(wallDuration), + id: now, + }, + ]); + setTimeout(async () => { + await runExitCleanup(); + process.exit(0); + }, 100); + } + } + }, + }); + return { type: 'handled' }; + case 'quit': setQuittingMessages(result.messages); setTimeout(async () => { @@ -567,5 +642,6 @@ export const useSlashCommandProcessor = ( commandContext, shellConfirmationRequest, confirmationRequest, + quitConfirmationRequest, }; }; diff --git a/packages/cli/src/ui/hooks/useQuitConfirmation.ts b/packages/cli/src/ui/hooks/useQuitConfirmation.ts new file mode 100644 index 00000000..39d680cb --- /dev/null +++ b/packages/cli/src/ui/hooks/useQuitConfirmation.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import { QuitChoice } from '../components/QuitConfirmationDialog.js'; + +export const useQuitConfirmation = () => { + const [isQuitConfirmationOpen, setIsQuitConfirmationOpen] = useState(false); + + const showQuitConfirmation = useCallback(() => { + setIsQuitConfirmationOpen(true); + }, []); + + const handleQuitConfirmationSelect = useCallback((choice: QuitChoice) => { + setIsQuitConfirmationOpen(false); + + if (choice === QuitChoice.QUIT) { + return { shouldQuit: true, action: 'quit' }; + } else if (choice === QuitChoice.SAVE_AND_QUIT) { + return { shouldQuit: true, action: 'save_and_quit' }; + } else if (choice === QuitChoice.SUMMARY_AND_QUIT) { + return { shouldQuit: true, action: 'summary_and_quit' }; + } + + // Default to quit if unknown choice + return { shouldQuit: true, action: 'quit' }; + }, []); + + return { + isQuitConfirmationOpen, + showQuitConfirmation, + handleQuitConfirmationSelect, + }; +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index c8568da8..9e7b603b 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -121,6 +121,11 @@ export type HistoryItemQuit = HistoryItemBase & { duration: string; }; +export type HistoryItemQuitConfirmation = HistoryItemBase & { + type: 'quit_confirmation'; + duration: string; +}; + export type HistoryItemToolGroup = HistoryItemBase & { type: 'tool_group'; tools: IndividualToolCallDisplay[]; @@ -154,6 +159,7 @@ export type HistoryItemWithoutId = | HistoryItemModelStats | HistoryItemToolStats | HistoryItemQuit + | HistoryItemQuitConfirmation | HistoryItemCompression; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -169,6 +175,7 @@ export enum MessageType { MODEL_STATS = 'model_stats', TOOL_STATS = 'tool_stats', QUIT = 'quit', + QUIT_CONFIRMATION = 'quit_confirmation', GEMINI = 'gemini', COMPRESSION = 'compression', } @@ -219,6 +226,12 @@ export type Message = duration: string; content?: string; } + | { + type: MessageType.QUIT_CONFIRMATION; + timestamp: Date; + duration: string; + content?: string; + } | { type: MessageType.COMPRESSION; compression: CompressionProps;