feat: add /quit-confirm

This commit is contained in:
pomelo-nwu
2025-09-08 22:56:27 +08:00
parent a321df0aae
commit 28896b4bf8
9 changed files with 278 additions and 2 deletions

View File

@@ -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,

View File

@@ -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) => {
))}
</Box>
)}
{showWelcomeBackDialog && welcomeBackInfo?.hasHistory && (
<WelcomeBackDialog
welcomeBackInfo={welcomeBackInfo}
onSelect={handleWelcomeBackSelection}
onClose={handleWelcomeBackClose}
/>
)}
{shouldShowIdePrompt && currentIDE ? (
<IdeIntegrationNudge
@@ -1024,6 +1056,17 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
/>
) : isFolderTrustDialogOpen ? (
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
) : quitConfirmationRequest ? (
<QuitConfirmationDialog
onSelect={(choice) => {
const result = handleQuitConfirmationSelect(choice);
if (result?.shouldQuit) {
quitConfirmationRequest.onConfirm(true, result.action);
} else {
quitConfirmationRequest.onConfirm(false);
}
}}
/>
) : shellConfirmationRequest ? (
<ShellConfirmationDialog request={shellConfirmationRequest} />
) : confirmationRequest ? (

View File

@@ -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'],

View File

@@ -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

View File

@@ -81,6 +81,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{item.type === 'model_stats' && <ModelStatsDisplay />}
{item.type === 'tool_stats' && <ToolStatsDisplay />}
{item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />}
{item.type === 'quit_confirmation' && (
<SessionSummaryDisplay duration={item.duration} />
)}
{item.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={item.tools}

View File

@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum QuitChoice {
QUIT = 'quit',
SAVE_AND_QUIT = 'save_and_quit',
SUMMARY_AND_QUIT = 'summary_and_quit',
}
interface QuitConfirmationDialogProps {
onSelect: (choice: QuitChoice) => void;
}
export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
onSelect,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(QuitChoice.QUIT);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<QuitChoice>> = [
{
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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text>What would you like to do before exiting?</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
</Box>
);
};

View File

@@ -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<boolean>,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
_showQuitConfirmation: () => void,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
@@ -74,6 +76,10 @@ export const useSlashCommandProcessor = (
prompt: React.ReactNode;
onConfirm: (confirmed: boolean) => void;
}>(null);
const [quitConfirmationRequest, setQuitConfirmationRequest] =
useState<null | {
onConfirm: (shouldQuit: boolean, action?: string) => void;
}>(null);
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
new Set<string>(),
@@ -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,
};
};

View File

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

View File

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