mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge branch 'main' into feat/subagents
This commit is contained in:
@@ -437,7 +437,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await vi.advanceTimersByTimeAsync(1000); // Advance by 1000ms to trigger the setTimeout callback
|
||||
});
|
||||
|
||||
expect(mockSetQuittingMessages).toHaveBeenCalledWith([]);
|
||||
@@ -469,7 +469,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await vi.advanceTimersByTimeAsync(1000); // Advance by 1000ms to trigger the setTimeout callback
|
||||
});
|
||||
|
||||
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -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,
|
||||
@@ -56,6 +57,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[]>([]);
|
||||
@@ -76,6 +78,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>(),
|
||||
@@ -143,11 +149,21 @@ 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',
|
||||
compression: message.compression,
|
||||
};
|
||||
} else if (message.type === MessageType.SUMMARY) {
|
||||
historyItemContent = {
|
||||
type: 'summary',
|
||||
summary: message.summary,
|
||||
};
|
||||
} else {
|
||||
historyItemContent = {
|
||||
type: message.type,
|
||||
@@ -409,12 +425,85 @@ 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) {
|
||||
// User cancelled the quit operation - do nothing
|
||||
return;
|
||||
}
|
||||
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('/summary')
|
||||
.then(() => {
|
||||
// Wait for user to see the summary result
|
||||
setTimeout(() => {
|
||||
handleSlashCommand('/quit');
|
||||
}, 1200);
|
||||
})
|
||||
.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(),
|
||||
);
|
||||
// Give user time to see the error message
|
||||
setTimeout(() => {
|
||||
handleSlashCommand('/quit');
|
||||
}, 1000);
|
||||
});
|
||||
} 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 () => {
|
||||
await runExitCleanup();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
}, 1000);
|
||||
return { type: 'handled' };
|
||||
|
||||
case 'submit_prompt':
|
||||
@@ -570,6 +659,7 @@ export const useSlashCommandProcessor = (
|
||||
setSessionShellAllowlist,
|
||||
setIsProcessing,
|
||||
setConfirmationRequest,
|
||||
session.stats,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -580,5 +670,6 @@ export const useSlashCommandProcessor = (
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
};
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -913,7 +913,7 @@ export const useGeminiStream = (
|
||||
}
|
||||
const restorableToolCalls = toolCalls.filter(
|
||||
(toolCall) =>
|
||||
(toolCall.request.name === 'replace' ||
|
||||
(toolCall.request.name === 'edit' ||
|
||||
toolCall.request.name === 'write_file') &&
|
||||
toolCall.status === 'awaiting_approval',
|
||||
);
|
||||
|
||||
39
packages/cli/src/ui/hooks/useQuitConfirmation.ts
Normal file
39
packages/cli/src/ui/hooks/useQuitConfirmation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @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.CANCEL) {
|
||||
return { shouldQuit: false, action: 'cancel' };
|
||||
} else 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 cancel if unknown choice
|
||||
return { shouldQuit: false, action: 'cancel' };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isQuitConfirmationOpen,
|
||||
showQuitConfirmation,
|
||||
handleQuitConfirmationSelect,
|
||||
};
|
||||
};
|
||||
119
packages/cli/src/ui/hooks/useWelcomeBack.ts
Normal file
119
packages/cli/src/ui/hooks/useWelcomeBack.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
getProjectSummaryInfo,
|
||||
type ProjectSummaryInfo,
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type Settings } from '../../config/settingsSchema.js';
|
||||
|
||||
export interface WelcomeBackState {
|
||||
welcomeBackInfo: ProjectSummaryInfo | null;
|
||||
showWelcomeBackDialog: boolean;
|
||||
welcomeBackChoice: 'restart' | 'continue' | null;
|
||||
shouldFillInput: boolean;
|
||||
inputFillText: string | null;
|
||||
}
|
||||
|
||||
export interface WelcomeBackActions {
|
||||
handleWelcomeBackSelection: (choice: 'restart' | 'continue') => void;
|
||||
handleWelcomeBackClose: () => void;
|
||||
checkWelcomeBack: () => Promise<void>;
|
||||
clearInputFill: () => void;
|
||||
}
|
||||
|
||||
export function useWelcomeBack(
|
||||
config: Config,
|
||||
submitQuery: (query: string) => void,
|
||||
buffer: { setText: (text: string) => void },
|
||||
settings: Settings,
|
||||
): WelcomeBackState & WelcomeBackActions {
|
||||
const [welcomeBackInfo, setWelcomeBackInfo] =
|
||||
useState<ProjectSummaryInfo | null>(null);
|
||||
const [showWelcomeBackDialog, setShowWelcomeBackDialog] = useState(false);
|
||||
const [welcomeBackChoice, setWelcomeBackChoice] = useState<
|
||||
'restart' | 'continue' | null
|
||||
>(null);
|
||||
const [shouldFillInput, setShouldFillInput] = useState(false);
|
||||
const [inputFillText, setInputFillText] = useState<string | null>(null);
|
||||
|
||||
// Check for conversation history on startup
|
||||
const checkWelcomeBack = useCallback(async () => {
|
||||
// Check if welcome back is enabled in settings
|
||||
if (settings.enableWelcomeBack === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await getProjectSummaryInfo();
|
||||
if (info.hasHistory) {
|
||||
setWelcomeBackInfo(info);
|
||||
setShowWelcomeBackDialog(true);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors - welcome back is not critical
|
||||
console.debug('Welcome back check failed:', error);
|
||||
}
|
||||
}, [settings.enableWelcomeBack]);
|
||||
|
||||
// Handle welcome back dialog selection
|
||||
const handleWelcomeBackSelection = useCallback(
|
||||
(choice: 'restart' | 'continue') => {
|
||||
setWelcomeBackChoice(choice);
|
||||
setShowWelcomeBackDialog(false);
|
||||
|
||||
if (choice === 'continue' && welcomeBackInfo?.content) {
|
||||
// Create the context message to fill in the input box
|
||||
const contextMessage = `@.qwen/PROJECT_SUMMARY.md, Based on our previous conversation,Let's continue?`;
|
||||
|
||||
// Set the input fill state instead of directly submitting
|
||||
setInputFillText(contextMessage);
|
||||
setShouldFillInput(true);
|
||||
}
|
||||
// If choice is 'restart', just close the dialog and continue normally
|
||||
},
|
||||
[welcomeBackInfo],
|
||||
);
|
||||
|
||||
const handleWelcomeBackClose = useCallback(() => {
|
||||
setWelcomeBackChoice('restart'); // Default to restart when closed
|
||||
setShowWelcomeBackDialog(false);
|
||||
}, []);
|
||||
|
||||
const clearInputFill = useCallback(() => {
|
||||
setShouldFillInput(false);
|
||||
setInputFillText(null);
|
||||
}, []);
|
||||
|
||||
// Handle input filling from welcome back
|
||||
useEffect(() => {
|
||||
if (shouldFillInput && inputFillText) {
|
||||
buffer.setText(inputFillText);
|
||||
clearInputFill();
|
||||
}
|
||||
}, [shouldFillInput, inputFillText, buffer, clearInputFill]);
|
||||
|
||||
// Check for welcome back on mount
|
||||
useEffect(() => {
|
||||
checkWelcomeBack();
|
||||
}, [checkWelcomeBack]);
|
||||
|
||||
return {
|
||||
// State
|
||||
welcomeBackInfo,
|
||||
showWelcomeBackDialog,
|
||||
welcomeBackChoice,
|
||||
shouldFillInput,
|
||||
inputFillText,
|
||||
// Actions
|
||||
handleWelcomeBackSelection,
|
||||
handleWelcomeBackClose,
|
||||
checkWelcomeBack,
|
||||
clearInputFill,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user