mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Merge branch 'main' into i18n
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
|
||||
@@ -804,7 +804,6 @@ export async function loadCliConfig(
|
||||
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
|
||||
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
vlmSwitchMode,
|
||||
|
||||
@@ -77,7 +77,6 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.disableAutoUpdate',
|
||||
disableUpdateNag: 'general.disableUpdateNag',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enablePromptCompletion: 'general.enablePromptCompletion',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
@@ -839,5 +838,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving user settings file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,16 +167,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePromptCompletion: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Prompt Completion',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable AI-powered prompt completion suggestions while typing.',
|
||||
showInDialog: true,
|
||||
},
|
||||
debugKeystrokeLogging: {
|
||||
type: 'boolean',
|
||||
label: 'Debug Keystroke Logging',
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
type AuthType,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
AuthEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Handles the initial authentication flow.
|
||||
@@ -26,13 +27,21 @@ export async function performInitialAuth(
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
await config.refreshAuth(authType, true);
|
||||
// The console.log is intentionally left out here.
|
||||
// We can add a dedicated startup message later if needed.
|
||||
|
||||
// Log authentication success
|
||||
const authEvent = new AuthEvent(authType, 'auto', 'success');
|
||||
logAuth(config, authEvent);
|
||||
} catch (e) {
|
||||
return t('Failed to login. Message: {{message}}', {
|
||||
message: getErrorMessage(e),
|
||||
});
|
||||
const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
|
||||
// Log authentication failure
|
||||
const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage);
|
||||
logAuth(config, authEvent);
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
logIdeConnection,
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import { initializeI18n } from '../i18n/index.js';
|
||||
@@ -41,10 +41,17 @@ export async function initializeApp(
|
||||
'auto';
|
||||
await initializeI18n(languageSetting);
|
||||
|
||||
const authError = await performInitialAuth(
|
||||
config,
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
);
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
const authError = await performInitialAuth(config, authType);
|
||||
|
||||
// Fallback to user select when initial authentication fails
|
||||
if (authError) {
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
const shouldOpenAuthDialog =
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { vi } from 'vitest';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { CommandKind } from './ui/commands/types.js';
|
||||
|
||||
// Mock core modules
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
@@ -727,6 +728,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'testcommand',
|
||||
description: 'a test command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Prompt from command' }],
|
||||
@@ -766,6 +768,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'confirm',
|
||||
description: 'a command that needs confirmation',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'confirm_shell_commands',
|
||||
commands: ['rm -rf /'],
|
||||
@@ -821,6 +824,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'noaction',
|
||||
description: 'unhandled type',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'unhandled',
|
||||
}),
|
||||
@@ -847,6 +851,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'testargs',
|
||||
description: 'a test command',
|
||||
kind: CommandKind.FILE,
|
||||
action: mockAction,
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
@@ -13,15 +13,56 @@ import {
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { CommandService } from './services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from './services/FileCommandLoader.js';
|
||||
import type { CommandContext } from './ui/commands/types.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from './ui/commands/types.js';
|
||||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||
|
||||
/**
|
||||
* Filters commands based on the allowed built-in command names.
|
||||
*
|
||||
* - Always includes FILE commands
|
||||
* - Only includes BUILT_IN commands if their name is in the allowed set
|
||||
* - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode
|
||||
*
|
||||
* @param commands All loaded commands
|
||||
* @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed)
|
||||
* @returns Filtered commands
|
||||
*/
|
||||
function filterCommandsForNonInteractive(
|
||||
commands: readonly SlashCommand[],
|
||||
allowedBuiltinCommandNames: Set<string>,
|
||||
): SlashCommand[] {
|
||||
return commands.filter((cmd) => {
|
||||
if (cmd.kind === CommandKind.FILE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Built-in commands: only include if in the allowed list
|
||||
if (cmd.kind === CommandKind.BUILT_IN) {
|
||||
return allowedBuiltinCommandNames.has(cmd.name);
|
||||
}
|
||||
|
||||
// Exclude other types (e.g., MCP_PROMPT) in non-interactive mode
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a slash command in a non-interactive environment.
|
||||
*
|
||||
* @param rawQuery The raw query string (should start with '/')
|
||||
* @param abortController Controller to cancel the operation
|
||||
* @param config The configuration object
|
||||
* @param settings The loaded settings
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. If not provided or empty, only file commands are available.
|
||||
* @returns A Promise that resolves to `PartListUnion` if a valid command is
|
||||
* found and results in a prompt, or `undefined` otherwise.
|
||||
* @throws {FatalInputError} if the command result is not supported in
|
||||
@@ -32,21 +73,35 @@ export const handleSlashCommand = async (
|
||||
abortController: AbortController,
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<PartListUnion | undefined> => {
|
||||
const trimmed = rawQuery.trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only custom commands are supported for now.
|
||||
const loaders = [new FileCommandLoader(config)];
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||
: [new FileCommandLoader(config)];
|
||||
|
||||
const commandService = await CommandService.create(
|
||||
loaders,
|
||||
abortController.signal,
|
||||
);
|
||||
const commands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
commands,
|
||||
allowedBuiltinSet,
|
||||
);
|
||||
|
||||
const { commandToExecute, args } = parseSlashCommand(rawQuery, commands);
|
||||
const { commandToExecute, args } = parseSlashCommand(
|
||||
rawQuery,
|
||||
filteredCommands,
|
||||
);
|
||||
|
||||
if (commandToExecute) {
|
||||
if (commandToExecute.action) {
|
||||
@@ -107,3 +162,44 @@ export const handleSlashCommand = async (
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all available slash commands for the current configuration.
|
||||
*
|
||||
* @param config The configuration object
|
||||
* @param settings The loaded settings
|
||||
* @param abortSignal Signal to cancel the loading process
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. If not provided or empty, only file commands are available.
|
||||
* @returns A Promise that resolves to an array of SlashCommand objects
|
||||
*/
|
||||
export const getAvailableCommands = async (
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
abortSignal: AbortSignal,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<SlashCommand[]> => {
|
||||
try {
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||
: [new FileCommandLoader(config)];
|
||||
|
||||
const commandService = await CommandService.create(loaders, abortSignal);
|
||||
const commands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
commands,
|
||||
allowedBuiltinSet,
|
||||
);
|
||||
|
||||
// Filter out hidden commands
|
||||
return filteredCommands.filter((cmd) => !cmd.hidden);
|
||||
} catch (error) {
|
||||
// Handle errors gracefully - log and return empty array
|
||||
console.error('Error loading available commands:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type HistoryItem,
|
||||
ToolCallStatus,
|
||||
type HistoryItemWithoutId,
|
||||
AuthState,
|
||||
} from './types.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import {
|
||||
@@ -48,7 +47,6 @@ import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
@@ -94,10 +92,12 @@ import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
|
||||
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
@@ -349,19 +349,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
onAuthError,
|
||||
isAuthDialogOpen,
|
||||
isAuthenticating,
|
||||
pendingAuthType,
|
||||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
openAuthDialog,
|
||||
} = useAuthCommand(settings, config);
|
||||
|
||||
// Qwen OAuth authentication state
|
||||
const {
|
||||
isQwenAuth,
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
cancelQwenAuth,
|
||||
} = useQwenAuth(settings, isAuthenticating);
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, config, historyManager.addItem);
|
||||
|
||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
||||
config,
|
||||
@@ -371,19 +364,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setModelSwitchedFromQuotaError,
|
||||
});
|
||||
|
||||
// Handle Qwen OAuth timeout
|
||||
const handleQwenAuthTimeout = useCallback(() => {
|
||||
onAuthError(t('Qwen OAuth authentication timed out. Please try again.'));
|
||||
cancelQwenAuth();
|
||||
setAuthState(AuthState.Updating);
|
||||
}, [onAuthError, cancelQwenAuth, setAuthState]);
|
||||
|
||||
// Handle Qwen OAuth cancel
|
||||
const handleQwenAuthCancel = useCallback(() => {
|
||||
onAuthError(t('Qwen OAuth authentication cancelled.'));
|
||||
cancelQwenAuth();
|
||||
setAuthState(AuthState.Updating);
|
||||
}, [onAuthError, cancelQwenAuth, setAuthState]);
|
||||
useInitializationAuthError(initializationResult.authError, onAuthError);
|
||||
|
||||
// Sync user tier from config when authentication changes
|
||||
// TODO: Implement getUserTier() method on Config if needed
|
||||
@@ -395,6 +376,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Check for enforced auth type mismatch
|
||||
useEffect(() => {
|
||||
// Check for initialization error first
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.enforcedType &&
|
||||
settings.merged.security?.auth.selectedType &&
|
||||
@@ -951,6 +934,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
settings.merged.ui?.customWittyPhrases,
|
||||
);
|
||||
|
||||
useAttentionNotifications({
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
});
|
||||
|
||||
// Dialog close functionality
|
||||
const { closeAnyOpenDialog } = useDialogClose({
|
||||
isThemeDialogOpen,
|
||||
@@ -959,7 +948,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleApprovalModeSelect,
|
||||
isAuthDialogOpen,
|
||||
handleAuthSelect,
|
||||
selectedAuthType: settings.merged.security?.auth?.selectedType,
|
||||
pendingAuthType,
|
||||
isEditorDialogOpen,
|
||||
exitEditorDialog,
|
||||
isSettingsDialogOpen,
|
||||
@@ -1201,7 +1190,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isVisionSwitchDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthDialogOpen ||
|
||||
(isAuthenticating && isQwenAuthenticating) ||
|
||||
isAuthenticating ||
|
||||
isEditorDialogOpen ||
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
@@ -1224,12 +1213,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isConfigInitialized,
|
||||
authError,
|
||||
isAuthDialogOpen,
|
||||
pendingAuthType,
|
||||
// Qwen OAuth state
|
||||
isQwenAuth,
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
@@ -1319,12 +1305,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isConfigInitialized,
|
||||
authError,
|
||||
isAuthDialogOpen,
|
||||
pendingAuthType,
|
||||
// Qwen OAuth state
|
||||
isQwenAuth,
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
@@ -1418,9 +1401,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleAuthSelect,
|
||||
setAuthState,
|
||||
onAuthError,
|
||||
// Qwen OAuth handlers
|
||||
handleQwenAuthTimeout,
|
||||
handleQwenAuthCancel,
|
||||
cancelAuthentication,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
@@ -1454,9 +1435,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleAuthSelect,
|
||||
setAuthState,
|
||||
onAuthError,
|
||||
// Qwen OAuth handlers
|
||||
handleQwenAuthTimeout,
|
||||
handleQwenAuthCancel,
|
||||
cancelAuthentication,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
|
||||
@@ -9,6 +9,53 @@ import { AuthDialog } from './AuthDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { UIActionsContext } from '../contexts/UIActionsContext.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import type { UIActions } from '../contexts/UIActionsContext.js';
|
||||
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState => {
|
||||
// AuthDialog only uses authError and pendingAuthType
|
||||
const baseState = {
|
||||
authError: null,
|
||||
pendingAuthType: undefined,
|
||||
} as Partial<UIState>;
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
...overrides,
|
||||
} as UIState;
|
||||
};
|
||||
|
||||
const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
|
||||
// AuthDialog only uses handleAuthSelect
|
||||
const baseActions = {
|
||||
handleAuthSelect: vi.fn(),
|
||||
} as Partial<UIActions>;
|
||||
|
||||
return {
|
||||
...baseActions,
|
||||
...overrides,
|
||||
} as UIActions;
|
||||
};
|
||||
|
||||
const renderAuthDialog = (
|
||||
settings: LoadedSettings,
|
||||
uiStateOverrides: Partial<UIState> = {},
|
||||
uiActionsOverrides: Partial<UIActions> = {},
|
||||
) => {
|
||||
const uiState = createMockUIState(uiStateOverrides);
|
||||
const uiActions = createMockUIActions(uiActionsOverrides);
|
||||
|
||||
return renderWithProviders(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<AuthDialog />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>,
|
||||
{ settings },
|
||||
);
|
||||
};
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -66,13 +113,9 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog
|
||||
onSelect={() => {}}
|
||||
settings={settings}
|
||||
initialErrorMessage="GEMINI_API_KEY environment variable not found"
|
||||
/>,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings, {
|
||||
authError: 'GEMINI_API_KEY environment variable not found',
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'GEMINI_API_KEY environment variable not found',
|
||||
@@ -116,9 +159,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
@@ -162,9 +203,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
expect(lastFrame()).not.toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
@@ -208,9 +247,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
@@ -255,9 +292,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 2. OpenAI');
|
||||
@@ -297,9 +332,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Default is Qwen OAuth (first option)
|
||||
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
||||
@@ -341,9 +374,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
|
||||
// it will just show the default Qwen OAuth option
|
||||
@@ -352,7 +383,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('should prevent exiting when no auth method is selected and show error message', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const handleAuthSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
@@ -386,8 +417,10 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
const { lastFrame, stdin, unmount } = renderAuthDialog(
|
||||
settings,
|
||||
{},
|
||||
{ handleAuthSelect },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -395,16 +428,16 @@ describe('AuthDialog', () => {
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should show error message instead of calling onSelect
|
||||
// Should show error message instead of calling handleAuthSelect
|
||||
expect(lastFrame()).toContain(
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
||||
);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(handleAuthSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not exit if there is already an error message', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const handleAuthSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
@@ -438,12 +471,10 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog
|
||||
onSelect={onSelect}
|
||||
settings={settings}
|
||||
initialErrorMessage="Initial error"
|
||||
/>,
|
||||
const { lastFrame, stdin, unmount } = renderAuthDialog(
|
||||
settings,
|
||||
{ authError: 'Initial error' },
|
||||
{ handleAuthSelect },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -453,13 +484,13 @@ describe('AuthDialog', () => {
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should not call onSelect
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
// Should not call handleAuthSelect
|
||||
expect(handleAuthSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow exiting when auth method is already selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const handleAuthSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
@@ -493,8 +524,10 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
const { stdin, unmount } = renderAuthDialog(
|
||||
settings,
|
||||
{},
|
||||
{ handleAuthSelect },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -502,8 +535,8 @@ describe('AuthDialog', () => {
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should call onSelect with undefined to exit
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||
// Should call handleAuthSelect with undefined to exit
|
||||
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,28 +8,15 @@ import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface AuthDialogProps {
|
||||
onSelect: (
|
||||
authMethod: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
) => void;
|
||||
settings: LoadedSettings;
|
||||
initialErrorMessage?: string | null;
|
||||
}
|
||||
|
||||
function parseDefaultAuthType(
|
||||
defaultAuthType: string | undefined,
|
||||
): AuthType | null {
|
||||
@@ -42,15 +29,14 @@ function parseDefaultAuthType(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AuthDialog({
|
||||
onSelect,
|
||||
settings,
|
||||
initialErrorMessage,
|
||||
}: AuthDialogProps): React.JSX.Element {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(
|
||||
initialErrorMessage || null,
|
||||
);
|
||||
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
|
||||
export function AuthDialog(): React.JSX.Element {
|
||||
const { pendingAuthType, authError } = useUIState();
|
||||
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||
const settings = useSettings();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: AuthType.QWEN_OAUTH,
|
||||
@@ -67,10 +53,17 @@ export function AuthDialog({
|
||||
const initialAuthIndex = Math.max(
|
||||
0,
|
||||
items.findIndex((item) => {
|
||||
// Priority 1: pendingAuthType
|
||||
if (pendingAuthType) {
|
||||
return item.value === pendingAuthType;
|
||||
}
|
||||
|
||||
// Priority 2: settings.merged.security?.auth?.selectedType
|
||||
if (settings.merged.security?.auth?.selectedType) {
|
||||
return item.value === settings.merged.security?.auth?.selectedType;
|
||||
}
|
||||
|
||||
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
||||
const defaultAuthType = parseDefaultAuthType(
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'],
|
||||
);
|
||||
@@ -78,51 +71,29 @@ export function AuthDialog({
|
||||
return item.value === defaultAuthType;
|
||||
}
|
||||
|
||||
// Priority 4: default to QWEN_OAUTH
|
||||
return item.value === AuthType.QWEN_OAUTH;
|
||||
}),
|
||||
);
|
||||
|
||||
const handleAuthSelect = (authMethod: AuthType) => {
|
||||
if (authMethod === AuthType.USE_OPENAI) {
|
||||
setShowOpenAIKeyPrompt(true);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
const error = validateAuthMethod(authMethod);
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
onSelect(authMethod, SettingScope.User);
|
||||
}
|
||||
}
|
||||
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
|
||||
const currentSelectedAuthType =
|
||||
selectedIndex !== null
|
||||
? items[selectedIndex]?.value
|
||||
: items[initialAuthIndex]?.value;
|
||||
|
||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||
setErrorMessage(null);
|
||||
await onAuthSelect(authMethod, SettingScope.User);
|
||||
};
|
||||
|
||||
const handleOpenAIKeySubmit = (
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
) => {
|
||||
setShowOpenAIKeyPrompt(false);
|
||||
onSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAIKeyCancel = () => {
|
||||
setShowOpenAIKeyPrompt(false);
|
||||
setErrorMessage(
|
||||
t('OpenAI API key is required to use OpenAI authentication.'),
|
||||
);
|
||||
const handleHighlight = (authMethod: AuthType) => {
|
||||
const index = items.findIndex((item) => item.value === authMethod);
|
||||
setSelectedIndex(index);
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (showOpenAIKeyPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
// Prevent exit if there is an error message.
|
||||
// This means they user is not authenticated yet.
|
||||
@@ -138,33 +109,11 @@ export function AuthDialog({
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSelect(undefined, SettingScope.User);
|
||||
onAuthSelect(undefined, SettingScope.User);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
const getDefaultOpenAIConfig = () => {
|
||||
const fromSettings = settings.merged.security?.auth;
|
||||
const modelSettings = settings.merged.model;
|
||||
return {
|
||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||
};
|
||||
};
|
||||
|
||||
if (showOpenAIKeyPrompt) {
|
||||
const defaults = getDefaultOpenAIConfig();
|
||||
return (
|
||||
<OpenAIKeyPrompt
|
||||
defaultApiKey={defaults.apiKey}
|
||||
defaultBaseUrl={defaults.baseUrl}
|
||||
defaultModel={defaults.model}
|
||||
onSubmit={handleOpenAIKeySubmit}
|
||||
onCancel={handleOpenAIKeyCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -183,16 +132,26 @@ export function AuthDialog({
|
||||
items={items}
|
||||
initialIndex={initialAuthIndex}
|
||||
onSelect={handleAuthSelect}
|
||||
onHighlight={handleHighlight}
|
||||
/>
|
||||
</Box>
|
||||
{errorMessage && (
|
||||
{(authError || errorMessage) && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{errorMessage}</Text>
|
||||
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
|
||||
</Box>
|
||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Note: Your existing API key in settings.json will not be cleared
|
||||
when using Qwen OAuth. You can switch back to OpenAI authentication
|
||||
later if needed.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -4,38 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import type { AuthType, Config } from '@qwen-code/qwen-code-core';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { AuthState } from '../types.js';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
|
||||
export function validateAuthMethodWithSettings(
|
||||
authType: AuthType,
|
||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
): string | null {
|
||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||
if (enforcedType && enforcedType !== authType) {
|
||||
return t(
|
||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
||||
{
|
||||
enforcedType,
|
||||
currentType: authType,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (settings.merged.security?.auth?.useExternal) {
|
||||
return null;
|
||||
}
|
||||
return validateAuthMethod(authType);
|
||||
}
|
||||
|
||||
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
config: Config,
|
||||
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
||||
) => {
|
||||
const unAuthenticated =
|
||||
settings.merged.security?.auth?.selectedType === undefined;
|
||||
|
||||
@@ -47,6 +37,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
|
||||
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
|
||||
pendingAuthType,
|
||||
isAuthenticating,
|
||||
);
|
||||
|
||||
const onAuthError = useCallback(
|
||||
(error: string | null) => {
|
||||
@@ -59,94 +57,132 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
[setAuthError, setAuthState],
|
||||
);
|
||||
|
||||
// Authentication flow
|
||||
useEffect(() => {
|
||||
const authFlow = async () => {
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
if (isAuthDialogOpen || !authType) {
|
||||
return;
|
||||
}
|
||||
const handleAuthFailure = useCallback(
|
||||
(error: unknown) => {
|
||||
setIsAuthenticating(false);
|
||||
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
|
||||
onAuthError(errorMessage);
|
||||
|
||||
const validationError = validateAuthMethodWithSettings(
|
||||
authType,
|
||||
settings,
|
||||
);
|
||||
if (validationError) {
|
||||
onAuthError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
await config.refreshAuth(authType);
|
||||
console.log(`Authenticated via "${authType}".`);
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
} catch (e) {
|
||||
onAuthError(
|
||||
t('Failed to login. Message: {{message}}', {
|
||||
message: getErrorMessage(e),
|
||||
}),
|
||||
// Log authentication failure
|
||||
if (pendingAuthType) {
|
||||
const authEvent = new AuthEvent(
|
||||
pendingAuthType,
|
||||
'manual',
|
||||
'error',
|
||||
errorMessage,
|
||||
);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
logAuth(config, authEvent);
|
||||
}
|
||||
};
|
||||
},
|
||||
[onAuthError, pendingAuthType, config],
|
||||
);
|
||||
|
||||
void authFlow();
|
||||
}, [isAuthDialogOpen, settings, config, onAuthError]);
|
||||
|
||||
// Handle auth selection from dialog
|
||||
const handleAuthSelect = useCallback(
|
||||
const handleAuthSuccess = useCallback(
|
||||
async (
|
||||
authType: AuthType | undefined,
|
||||
authType: AuthType,
|
||||
scope: SettingScope,
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
if (authType) {
|
||||
await clearCachedCredentialFile();
|
||||
try {
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
|
||||
// Save OpenAI credentials if provided
|
||||
if (credentials) {
|
||||
// Update Config's internal generationConfig before calling refreshAuth
|
||||
// This ensures refreshAuth has access to the new credentials
|
||||
config.updateCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
|
||||
// Also set environment variables for compatibility with other parts of the code
|
||||
if (credentials.apiKey) {
|
||||
// Only update credentials if not switching to QWEN_OAUTH,
|
||||
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
||||
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
||||
if (credentials?.apiKey != null) {
|
||||
settings.setValue(
|
||||
scope,
|
||||
'security.auth.apiKey',
|
||||
credentials.apiKey,
|
||||
);
|
||||
}
|
||||
if (credentials.baseUrl) {
|
||||
if (credentials?.baseUrl != null) {
|
||||
settings.setValue(
|
||||
scope,
|
||||
'security.auth.baseUrl',
|
||||
credentials.baseUrl,
|
||||
);
|
||||
}
|
||||
if (credentials.model) {
|
||||
if (credentials?.model != null) {
|
||||
settings.setValue(scope, 'model.name', credentials.model);
|
||||
}
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
setPendingAuthType(undefined);
|
||||
setIsAuthDialogOpen(false);
|
||||
setIsAuthenticating(false);
|
||||
|
||||
// Log authentication success
|
||||
const authEvent = new AuthEvent(authType, 'manual', 'success');
|
||||
logAuth(config, authEvent);
|
||||
|
||||
// Show success message
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Authenticated successfully with ${authType} credentials.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
[settings, config],
|
||||
[settings, handleAuthFailure, config, addItem],
|
||||
);
|
||||
|
||||
const performAuth = useCallback(
|
||||
async (
|
||||
authType: AuthType,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
handleAuthSuccess(authType, scope, credentials);
|
||||
} catch (e) {
|
||||
handleAuthFailure(e);
|
||||
}
|
||||
},
|
||||
[config, handleAuthSuccess, handleAuthFailure],
|
||||
);
|
||||
|
||||
const handleAuthSelect = useCallback(
|
||||
async (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
if (!authType) {
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAuthType(authType);
|
||||
setAuthError(null);
|
||||
setIsAuthDialogOpen(false);
|
||||
setIsAuthenticating(true);
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
if (credentials) {
|
||||
config.updateCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
await performAuth(authType, scope, credentials);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await performAuth(authType, scope);
|
||||
},
|
||||
[config, performAuth],
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
@@ -154,8 +190,45 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
}, []);
|
||||
|
||||
const cancelAuthentication = useCallback(() => {
|
||||
if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||
cancelQwenAuth();
|
||||
}
|
||||
|
||||
// Log authentication cancellation
|
||||
if (isAuthenticating && pendingAuthType) {
|
||||
const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled');
|
||||
logAuth(config, authEvent);
|
||||
}
|
||||
|
||||
// Do not reset pendingAuthType here, persist the previously selected type.
|
||||
setIsAuthenticating(false);
|
||||
}, []);
|
||||
setIsAuthDialogOpen(true);
|
||||
setAuthError(null);
|
||||
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
|
||||
|
||||
/**
|
||||
/**
|
||||
* We previously used a useEffect to trigger authentication automatically when
|
||||
* settings.security.auth.selectedType changed. This caused problems: if authentication failed,
|
||||
* the UI could get stuck, since settings.json would update before success. Now, we
|
||||
* update selectedType in settings only when authentication fully succeeds.
|
||||
* Authentication is triggered explicitly—either during initial app startup or when the
|
||||
* user switches methods—not reactively through settings changes. This avoids repeated
|
||||
* or broken authentication cycles.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthType &&
|
||||
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
|
||||
defaultAuthType as AuthType,
|
||||
)
|
||||
) {
|
||||
onAuthError(
|
||||
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
|
||||
);
|
||||
}
|
||||
}, [onAuthError]);
|
||||
|
||||
return {
|
||||
authState,
|
||||
@@ -164,6 +237,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
onAuthError,
|
||||
isAuthDialogOpen,
|
||||
isAuthenticating,
|
||||
pendingAuthType,
|
||||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
|
||||
@@ -12,9 +12,9 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
@@ -26,6 +26,9 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { AuthState } from '../types.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import process from 'node:process';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
@@ -56,6 +59,16 @@ export const DialogManager = ({
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||
uiState;
|
||||
|
||||
const getDefaultOpenAIConfig = () => {
|
||||
const fromSettings = settings.merged.security?.auth;
|
||||
const modelSettings = settings.merged.model;
|
||||
return {
|
||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||
};
|
||||
};
|
||||
|
||||
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
|
||||
return (
|
||||
<WelcomeBackDialog
|
||||
@@ -207,39 +220,56 @@ export const DialogManager = ({
|
||||
if (uiState.isVisionSwitchDialogOpen) {
|
||||
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
|
||||
}
|
||||
|
||||
if (uiState.isAuthDialogOpen || uiState.authError) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<AuthDialog />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isAuthenticating) {
|
||||
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active
|
||||
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) {
|
||||
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
|
||||
const defaults = getDefaultOpenAIConfig();
|
||||
return (
|
||||
<QwenOAuthProgress
|
||||
deviceAuth={uiState.deviceAuth || undefined}
|
||||
authStatus={uiState.authStatus}
|
||||
authMessage={uiState.authMessage}
|
||||
onTimeout={uiActions.handleQwenAuthTimeout}
|
||||
onCancel={uiActions.handleQwenAuthCancel}
|
||||
<OpenAIKeyPrompt
|
||||
onSubmit={(apiKey, baseUrl, model) => {
|
||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
defaultApiKey={defaults.apiKey}
|
||||
defaultBaseUrl={defaults.baseUrl}
|
||||
defaultModel={defaults.model}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default auth progress for other auth types
|
||||
return (
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
uiActions.onAuthError('Authentication cancelled.');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isAuthDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<AuthDialog
|
||||
onSelect={uiActions.handleAuthSelect}
|
||||
settings={settings}
|
||||
initialErrorMessage={uiState.authError}
|
||||
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||
return (
|
||||
<QwenOAuthProgress
|
||||
deviceAuth={uiState.qwenAuthState.deviceAuth || undefined}
|
||||
authStatus={uiState.qwenAuthState.authStatus}
|
||||
authMessage={uiState.qwenAuthState.authMessage}
|
||||
onTimeout={() => {
|
||||
uiActions.onAuthError('Qwen OAuth authentication timed out.');
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
onCancel={() => {
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
|
||||
@@ -164,11 +164,6 @@ describe('InputPrompt', () => {
|
||||
setActiveSuggestionIndex: vi.fn(),
|
||||
setShowSuggestions: vi.fn(),
|
||||
handleAutocomplete: vi.fn(),
|
||||
promptCompletion: {
|
||||
text: '',
|
||||
accept: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { logicalPosToOffset } from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
|
||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||
@@ -92,7 +91,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandContext,
|
||||
placeholder,
|
||||
focus = true,
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
@@ -527,16 +525,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Tab key for ghost text acceptance
|
||||
if (
|
||||
key.name === 'tab' &&
|
||||
!completion.showSuggestions &&
|
||||
completion.promptCompletion.text
|
||||
) {
|
||||
completion.promptCompletion.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setCommandSearchActive(true);
|
||||
@@ -658,18 +646,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
completion.promptCompletion.text &&
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.meta
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
},
|
||||
[
|
||||
focus,
|
||||
@@ -704,118 +680,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
|
||||
const getGhostTextLines = useCallback(() => {
|
||||
if (
|
||||
!completion.promptCompletion.text ||
|
||||
!buffer.text ||
|
||||
!completion.promptCompletion.text.startsWith(buffer.text)
|
||||
) {
|
||||
return { inlineGhost: '', additionalLines: [] };
|
||||
}
|
||||
|
||||
const ghostSuffix = completion.promptCompletion.text.slice(
|
||||
buffer.text.length,
|
||||
);
|
||||
if (!ghostSuffix) {
|
||||
return { inlineGhost: '', additionalLines: [] };
|
||||
}
|
||||
|
||||
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
|
||||
const usedWidth = stringWidth(textBeforeCursor);
|
||||
const remainingWidth = Math.max(0, inputWidth - usedWidth);
|
||||
|
||||
const ghostTextLinesRaw = ghostSuffix.split('\n');
|
||||
const firstLineRaw = ghostTextLinesRaw.shift() || '';
|
||||
|
||||
let inlineGhost = '';
|
||||
let remainingFirstLine = '';
|
||||
|
||||
if (stringWidth(firstLineRaw) <= remainingWidth) {
|
||||
inlineGhost = firstLineRaw;
|
||||
} else {
|
||||
const words = firstLineRaw.split(' ');
|
||||
let currentLine = '';
|
||||
let wordIdx = 0;
|
||||
for (const word of words) {
|
||||
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (stringWidth(prospectiveLine) > remainingWidth) {
|
||||
break;
|
||||
}
|
||||
currentLine = prospectiveLine;
|
||||
wordIdx++;
|
||||
}
|
||||
inlineGhost = currentLine;
|
||||
if (words.length > wordIdx) {
|
||||
remainingFirstLine = words.slice(wordIdx).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
const linesToWrap = [];
|
||||
if (remainingFirstLine) {
|
||||
linesToWrap.push(remainingFirstLine);
|
||||
}
|
||||
linesToWrap.push(...ghostTextLinesRaw);
|
||||
const remainingGhostText = linesToWrap.join('\n');
|
||||
|
||||
const additionalLines: string[] = [];
|
||||
if (remainingGhostText) {
|
||||
const textLines = remainingGhostText.split('\n');
|
||||
for (const textLine of textLines) {
|
||||
const words = textLine.split(' ');
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
const prospectiveWidth = stringWidth(prospectiveLine);
|
||||
|
||||
if (prospectiveWidth > inputWidth) {
|
||||
if (currentLine) {
|
||||
additionalLines.push(currentLine);
|
||||
}
|
||||
|
||||
let wordToProcess = word;
|
||||
while (stringWidth(wordToProcess) > inputWidth) {
|
||||
let part = '';
|
||||
const wordCP = toCodePoints(wordToProcess);
|
||||
let partWidth = 0;
|
||||
let splitIndex = 0;
|
||||
for (let i = 0; i < wordCP.length; i++) {
|
||||
const char = wordCP[i];
|
||||
const charWidth = stringWidth(char);
|
||||
if (partWidth + charWidth > inputWidth) {
|
||||
break;
|
||||
}
|
||||
part += char;
|
||||
partWidth += charWidth;
|
||||
splitIndex = i + 1;
|
||||
}
|
||||
additionalLines.push(part);
|
||||
wordToProcess = cpSlice(wordToProcess, splitIndex);
|
||||
}
|
||||
currentLine = wordToProcess;
|
||||
} else {
|
||||
currentLine = prospectiveLine;
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
additionalLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { inlineGhost, additionalLines };
|
||||
}, [
|
||||
completion.promptCompletion.text,
|
||||
buffer.text,
|
||||
buffer.lines,
|
||||
buffer.cursor,
|
||||
inputWidth,
|
||||
]);
|
||||
|
||||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
||||
const getActiveCompletion = () => {
|
||||
if (commandSearchActive) return commandSearchCompletion;
|
||||
if (reverseSearchActive) return reverseSearchCompletion;
|
||||
@@ -888,134 +752,96 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText, visualIdxInRenderedSet) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
|
||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
);
|
||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
);
|
||||
|
||||
const visualStart = logicalStartCol;
|
||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
||||
const segments = buildSegmentsForVisualSlice(
|
||||
tokens,
|
||||
visualStart,
|
||||
visualEnd,
|
||||
);
|
||||
const visualStart = logicalStartCol;
|
||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
||||
const segments = buildSegmentsForVisualSlice(
|
||||
tokens,
|
||||
visualStart,
|
||||
visualEnd,
|
||||
);
|
||||
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
seg.text,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
seg.text,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
}
|
||||
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
additionalLines.map((ghostLine, index) => {
|
||||
const padding = Math.max(
|
||||
0,
|
||||
inputWidth - stringWidth(ghostLine),
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
key={`ghost-line-${index}`}
|
||||
color={theme.text.secondary}
|
||||
>
|
||||
{ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
);
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>{renderedLine}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -18,6 +19,16 @@ interface OpenAIKeyPromptProps {
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
baseUrl: z
|
||||
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
|
||||
.optional(),
|
||||
model: z.string().min(1, 'Model must be a non-empty string').optional(),
|
||||
});
|
||||
|
||||
export type OpenAICredentials = z.infer<typeof credentialSchema>;
|
||||
|
||||
export function OpenAIKeyPrompt({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
@@ -31,6 +42,34 @@ export function OpenAIKeyPrompt({
|
||||
const [currentField, setCurrentField] = useState<
|
||||
'apiKey' | 'baseUrl' | 'model'
|
||||
>('apiKey');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const validateAndSubmit = () => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const validated = credentialSchema.parse({
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
model: model.trim() || undefined,
|
||||
});
|
||||
|
||||
onSubmit(
|
||||
validated.apiKey,
|
||||
validated.baseUrl === '' ? '' : validated.baseUrl || '',
|
||||
validated.model || '',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errorMessage = error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
setValidationError(`Invalid credentials: ${errorMessage}`);
|
||||
} else {
|
||||
setValidationError('Failed to validate credentials');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
@@ -52,7 +91,7 @@ export function OpenAIKeyPrompt({
|
||||
} else if (currentField === 'model') {
|
||||
// 只有在提交时才检查 API key 是否为空
|
||||
if (apiKey.trim()) {
|
||||
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
|
||||
validateAndSubmit();
|
||||
} else {
|
||||
// 如果 API key 为空,回到 API key 字段
|
||||
setCurrentField('apiKey');
|
||||
@@ -168,6 +207,11 @@ export function OpenAIKeyPrompt({
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
OpenAI Configuration Required
|
||||
</Text>
|
||||
{validationError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{validationError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
Please enter your OpenAI configuration. You can get an API key from{' '}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import type { Key } from '../contexts/KeypressContext.js';
|
||||
|
||||
@@ -42,12 +42,13 @@ describe('QwenOAuthProgress', () => {
|
||||
let keypressHandler: ((key: Key) => void) | null = null;
|
||||
|
||||
const createMockDeviceAuth = (
|
||||
overrides: Partial<DeviceAuthorizationInfo> = {},
|
||||
): DeviceAuthorizationInfo => ({
|
||||
overrides: Partial<DeviceAuthorizationData> = {},
|
||||
): DeviceAuthorizationData => ({
|
||||
verification_uri: 'https://example.com/device',
|
||||
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
|
||||
user_code: 'ABC123',
|
||||
expires_in: 300,
|
||||
device_code: 'test-device-code',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -55,7 +56,7 @@ describe('QwenOAuthProgress', () => {
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<{
|
||||
deviceAuth: DeviceAuthorizationInfo;
|
||||
deviceAuth: DeviceAuthorizationData;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
@@ -158,7 +159,7 @@ describe('QwenOAuthProgress', () => {
|
||||
});
|
||||
|
||||
it('should format time correctly', () => {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 125, // 2 minutes and 5 seconds
|
||||
};
|
||||
@@ -176,7 +177,7 @@ describe('QwenOAuthProgress', () => {
|
||||
});
|
||||
|
||||
it('should format single digit seconds with leading zero', () => {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 67, // 1 minute and 7 seconds
|
||||
};
|
||||
@@ -196,7 +197,7 @@ describe('QwenOAuthProgress', () => {
|
||||
|
||||
describe('Timer functionality', () => {
|
||||
it('should countdown and call onTimeout when timer expires', async () => {
|
||||
const deviceAuthWithShortTime: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWithShortTime: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 2, // 2 seconds
|
||||
};
|
||||
@@ -520,7 +521,7 @@ describe('QwenOAuthProgress', () => {
|
||||
|
||||
describe('Props changes', () => {
|
||||
it('should display initial timer value from deviceAuth', () => {
|
||||
const deviceAuthWith10Min: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWith10Min: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 600, // 10 minutes
|
||||
};
|
||||
|
||||
@@ -11,14 +11,14 @@ import Spinner from 'ink-spinner';
|
||||
import Link from 'ink-link';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { Colors } from '../colors.js';
|
||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface QwenOAuthProgressProps {
|
||||
onTimeout: () => void;
|
||||
onCancel: () => void;
|
||||
deviceAuth?: DeviceAuthorizationInfo;
|
||||
deviceAuth?: DeviceAuthorizationData;
|
||||
authStatus?:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
@@ -135,8 +135,8 @@ export function QwenOAuthProgress({
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (authStatus === 'timeout') {
|
||||
// Any key press in timeout state should trigger cancel to return to auth dialog
|
||||
if (authStatus === 'timeout' || authStatus === 'error') {
|
||||
// Any key press in timeout or error state should trigger cancel to return to auth dialog
|
||||
onCancel();
|
||||
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
onCancel();
|
||||
@@ -243,6 +243,35 @@ export function QwenOAuthProgress({
|
||||
);
|
||||
}
|
||||
|
||||
if (authStatus === 'error') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
Qwen OAuth Authentication Error
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{authMessage ||
|
||||
'An error occurred during authentication. Please try again.'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press any key to return to authentication type selection.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state when no device auth is available yet
|
||||
if (!deviceAuth) {
|
||||
return (
|
||||
|
||||
@@ -487,8 +487,11 @@ describe('SettingsDialog', () => {
|
||||
it('loops back when reaching the end of an enum', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
theme: StringEnum.BAZ,
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
const component = (
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
@@ -1268,7 +1271,6 @@ describe('SettingsDialog', () => {
|
||||
vimMode: true,
|
||||
disableAutoUpdate: true,
|
||||
debugKeystrokeLogging: true,
|
||||
enablePromptCompletion: true,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: true,
|
||||
@@ -1514,7 +1516,6 @@ describe('SettingsDialog', () => {
|
||||
vimMode: false,
|
||||
disableAutoUpdate: false,
|
||||
debugKeystrokeLogging: false,
|
||||
enablePromptCompletion: false,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
|
||||
@@ -10,8 +10,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -22,6 +20,10 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -44,8 +46,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -56,6 +56,10 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -78,8 +82,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -90,6 +92,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -112,8 +118,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
||||
│ │
|
||||
│ Disable Auto Update false* │
|
||||
│ │
|
||||
│ Enable Prompt Completion false* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -124,6 +128,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false* │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -146,8 +154,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Disable Auto Update (Modified in System) false │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -158,6 +164,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -180,8 +190,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging (Modified in Workspace) false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -192,6 +200,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -214,8 +226,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -226,6 +236,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -248,8 +262,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -260,6 +272,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -282,8 +298,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -294,6 +308,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -316,8 +334,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
│ Enable Prompt Completion true* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
@@ -328,6 +344,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips true* │
|
||||
│ │
|
||||
│ Hide Banner false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -58,7 +58,7 @@ export function CompressionMessage({
|
||||
'Could not compress chat history due to a token counting error.',
|
||||
);
|
||||
case CompressionStatus.NOOP:
|
||||
return t('Chat history is already compressed.');
|
||||
return 'Nothing to compress.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ describe('BaseSelectionList', () => {
|
||||
expect(output).not.toContain('Item 5');
|
||||
});
|
||||
|
||||
it('should scroll up when activeIndex moves before the visible window', async () => {
|
||||
it.skip('should scroll up when activeIndex moves before the visible window', async () => {
|
||||
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
|
||||
|
||||
await updateActiveIndex(4);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { type SettingScope } from '../../config/settings.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
||||
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
|
||||
export interface UIActions {
|
||||
handleThemeSelect: (
|
||||
@@ -30,12 +31,11 @@ export interface UIActions {
|
||||
handleAuthSelect: (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
credentials?: OpenAICredentials,
|
||||
) => Promise<void>;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
onAuthError: (error: string) => void;
|
||||
// Qwen OAuth handlers
|
||||
handleQwenAuthTimeout: () => void;
|
||||
handleQwenAuthCancel: () => void;
|
||||
cancelAuthentication: () => void;
|
||||
handleEditorSelect: (
|
||||
editorType: EditorType | undefined,
|
||||
scope: SettingScope,
|
||||
|
||||
@@ -16,10 +16,11 @@ import type {
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
} from '../types.js';
|
||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type {
|
||||
AuthType,
|
||||
IdeContext,
|
||||
ApprovalMode,
|
||||
UserTierId,
|
||||
@@ -49,18 +50,9 @@ export interface UIState {
|
||||
isConfigInitialized: boolean;
|
||||
authError: string | null;
|
||||
isAuthDialogOpen: boolean;
|
||||
pendingAuthType: AuthType | undefined;
|
||||
// Qwen OAuth state
|
||||
isQwenAuth: boolean;
|
||||
isQwenAuthenticating: boolean;
|
||||
deviceAuth: DeviceAuthorizationInfo | null;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'rate_limit';
|
||||
authMessage: string | null;
|
||||
qwenAuthState: QwenAuthState;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
corgiMode: boolean;
|
||||
|
||||
151
packages/cli/src/ui/hooks/useAttentionNotifications.test.ts
Normal file
151
packages/cli/src/ui/hooks/useAttentionNotifications.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { StreamingState } from '../types.js';
|
||||
import {
|
||||
AttentionNotificationReason,
|
||||
notifyTerminalAttention,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
import {
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
|
||||
useAttentionNotifications,
|
||||
} from './useAttentionNotifications.js';
|
||||
|
||||
vi.mock('../../utils/attentionNotification.js', () => ({
|
||||
notifyTerminalAttention: vi.fn(),
|
||||
AttentionNotificationReason: {
|
||||
ToolApproval: 'tool_approval',
|
||||
LongTaskComplete: 'long_task_complete',
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedNotify = vi.mocked(notifyTerminalAttention);
|
||||
|
||||
describe('useAttentionNotifications', () => {
|
||||
beforeEach(() => {
|
||||
mockedNotify.mockReset();
|
||||
});
|
||||
|
||||
const render = (
|
||||
props?: Partial<Parameters<typeof useAttentionNotifications>[0]>,
|
||||
) =>
|
||||
renderHook(({ hookProps }) => useAttentionNotifications(hookProps), {
|
||||
initialProps: {
|
||||
hookProps: {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('notifies when tool approval is required while unfocused', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
);
|
||||
});
|
||||
|
||||
it('notifies when focus is lost after entering approval wait state', () => {
|
||||
const { rerender } = render({
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends a notification when a long task finishes while unfocused', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
|
||||
},
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not notify about long tasks when the CLI is focused', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
|
||||
},
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).not.toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat short responses as long tasks', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 5,
|
||||
},
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
63
packages/cli/src/ui/hooks/useAttentionNotifications.ts
Normal file
63
packages/cli/src/ui/hooks/useAttentionNotifications.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
import {
|
||||
notifyTerminalAttention,
|
||||
AttentionNotificationReason,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
|
||||
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
|
||||
|
||||
interface UseAttentionNotificationsOptions {
|
||||
isFocused: boolean;
|
||||
streamingState: StreamingState;
|
||||
elapsedTime: number;
|
||||
}
|
||||
|
||||
export const useAttentionNotifications = ({
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
}: UseAttentionNotificationsOptions) => {
|
||||
const awaitingNotificationSentRef = useRef(false);
|
||||
const respondingElapsedRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
streamingState === StreamingState.WaitingForConfirmation &&
|
||||
!isFocused &&
|
||||
!awaitingNotificationSentRef.current
|
||||
) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
|
||||
awaitingNotificationSentRef.current = true;
|
||||
}
|
||||
|
||||
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
|
||||
awaitingNotificationSentRef.current = false;
|
||||
}
|
||||
}, [isFocused, streamingState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
respondingElapsedRef.current = elapsedTime;
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamingState === StreamingState.Idle) {
|
||||
const wasLongTask =
|
||||
respondingElapsedRef.current >=
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
|
||||
if (wasLongTask && !isFocused) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
|
||||
}
|
||||
// Reset tracking for next task
|
||||
respondingElapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
}, [streamingState, elapsedTime, isFocused]);
|
||||
};
|
||||
@@ -83,9 +83,7 @@ const setupMocks = ({
|
||||
|
||||
describe('useCommandCompletion', () => {
|
||||
const mockCommandContext = {} as CommandContext;
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => false,
|
||||
} as Config;
|
||||
const mockConfig = {} as Config;
|
||||
const testDirs: string[] = [];
|
||||
const testRootDir = '/';
|
||||
|
||||
@@ -516,81 +514,4 @@ describe('useCommandCompletion', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt completion filtering', () => {
|
||||
it('should not trigger prompt completion for line comments', async () => {
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => true,
|
||||
} as Config;
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest('// This is a line comment');
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
});
|
||||
|
||||
// Should not trigger prompt completion for comments
|
||||
expect(result.current.suggestions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not trigger prompt completion for block comments', async () => {
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => true,
|
||||
} as Config;
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest(
|
||||
'/* This is a block comment */',
|
||||
);
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
});
|
||||
|
||||
// Should not trigger prompt completion for comments
|
||||
expect(result.current.suggestions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should trigger prompt completion for regular text when enabled', async () => {
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => true,
|
||||
} as Config;
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const textBuffer = useTextBufferForTest(
|
||||
'This is regular text that should trigger completion',
|
||||
);
|
||||
const completion = useCommandCompletion(
|
||||
textBuffer,
|
||||
testDirs,
|
||||
testRootDir,
|
||||
[],
|
||||
mockCommandContext,
|
||||
false,
|
||||
mockConfig,
|
||||
);
|
||||
return { ...completion, textBuffer };
|
||||
});
|
||||
|
||||
// This test verifies that comments are filtered out while regular text is not
|
||||
expect(result.current.textBuffer.text).toBe(
|
||||
'This is regular text that should trigger completion',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,6 @@ import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import type { PromptCompletion } from './usePromptCompletion.js';
|
||||
import {
|
||||
usePromptCompletion,
|
||||
PROMPT_COMPLETION_MIN_LENGTH,
|
||||
} from './usePromptCompletion.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
|
||||
@@ -25,7 +20,6 @@ export enum CompletionMode {
|
||||
IDLE = 'IDLE',
|
||||
AT = 'AT',
|
||||
SLASH = 'SLASH',
|
||||
PROMPT = 'PROMPT',
|
||||
}
|
||||
|
||||
export interface UseCommandCompletionReturn {
|
||||
@@ -41,7 +35,6 @@ export interface UseCommandCompletionReturn {
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (indexToUse: number) => void;
|
||||
promptCompletion: PromptCompletion;
|
||||
}
|
||||
|
||||
export function useCommandCompletion(
|
||||
@@ -126,32 +119,13 @@ export function useCommandCompletion(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for prompt completion - only if enabled
|
||||
const trimmedText = buffer.text.trim();
|
||||
const isPromptCompletionEnabled =
|
||||
config?.getEnablePromptCompletion() ?? false;
|
||||
|
||||
if (
|
||||
isPromptCompletionEnabled &&
|
||||
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
|
||||
!isSlashCommand(trimmedText) &&
|
||||
!trimmedText.includes('@')
|
||||
) {
|
||||
return {
|
||||
completionMode: CompletionMode.PROMPT,
|
||||
query: trimmedText,
|
||||
completionStart: 0,
|
||||
completionEnd: trimmedText.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
completionMode: CompletionMode.IDLE,
|
||||
query: null,
|
||||
completionStart: -1,
|
||||
completionEnd: -1,
|
||||
};
|
||||
}, [cursorRow, cursorCol, buffer.lines, buffer.text, config]);
|
||||
}, [cursorRow, cursorCol, buffer.lines]);
|
||||
|
||||
useAtCompletion({
|
||||
enabled: completionMode === CompletionMode.AT,
|
||||
@@ -172,12 +146,6 @@ export function useCommandCompletion(
|
||||
setIsPerfectMatch,
|
||||
});
|
||||
|
||||
const promptCompletion = usePromptCompletion({
|
||||
buffer,
|
||||
config,
|
||||
enabled: completionMode === CompletionMode.PROMPT,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
@@ -264,6 +232,5 @@ export function useCommandCompletion(
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
promptCompletion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
|
||||
export interface DialogCloseOptions {
|
||||
// Theme dialog
|
||||
@@ -25,8 +26,9 @@ export interface DialogCloseOptions {
|
||||
handleAuthSelect: (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => Promise<void>;
|
||||
selectedAuthType: AuthType | undefined;
|
||||
pendingAuthType: AuthType | undefined;
|
||||
|
||||
// Editor dialog
|
||||
isEditorDialogOpen: boolean;
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { MockedFunction } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useGitBranchName } from './useGitBranchName.js';
|
||||
import { fs, vol } from 'memfs'; // For mocking fs
|
||||
import { spawnAsync as mockSpawnAsync } from '@qwen-code/qwen-code-core';
|
||||
import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock @qwen-code/qwen-code-core
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
@@ -19,7 +19,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
>('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...original,
|
||||
spawnAsync: vi.fn(),
|
||||
execCommand: vi.fn(),
|
||||
isCommandAvailable: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,6 +48,7 @@ describe('useGitBranchName', () => {
|
||||
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
|
||||
});
|
||||
vi.useFakeTimers(); // Use fake timers for async operations
|
||||
(isCommandAvailable as Mock).mockReturnValue({ available: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,11 +57,11 @@ describe('useGitBranchName', () => {
|
||||
});
|
||||
|
||||
it('should return branch name', async () => {
|
||||
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
|
||||
{
|
||||
stdout: 'main\n',
|
||||
} as { stdout: string; stderr: string },
|
||||
);
|
||||
(execCommand as Mock).mockResolvedValueOnce({
|
||||
stdout: 'main\n',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
|
||||
|
||||
await act(async () => {
|
||||
@@ -71,9 +73,7 @@ describe('useGitBranchName', () => {
|
||||
});
|
||||
|
||||
it('should return undefined if git command fails', async () => {
|
||||
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockRejectedValue(
|
||||
new Error('Git error'),
|
||||
);
|
||||
(execCommand as Mock).mockRejectedValue(new Error('Git error'));
|
||||
|
||||
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
|
||||
expect(result.current).toBeUndefined();
|
||||
@@ -86,16 +86,16 @@ describe('useGitBranchName', () => {
|
||||
});
|
||||
|
||||
it('should return short commit hash if branch is HEAD (detached state)', async () => {
|
||||
(
|
||||
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
|
||||
).mockImplementation(async (command: string, args: string[]) => {
|
||||
if (args.includes('--abbrev-ref')) {
|
||||
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string };
|
||||
} else if (args.includes('--short')) {
|
||||
return { stdout: 'a1b2c3d\n' } as { stdout: string; stderr: string };
|
||||
}
|
||||
return { stdout: '' } as { stdout: string; stderr: string };
|
||||
});
|
||||
(execCommand as Mock).mockImplementation(
|
||||
async (_command: string, args?: readonly string[] | null) => {
|
||||
if (args?.includes('--abbrev-ref')) {
|
||||
return { stdout: 'HEAD\n', stderr: '', code: 0 };
|
||||
} else if (args?.includes('--short')) {
|
||||
return { stdout: 'a1b2c3d\n', stderr: '', code: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', code: 0 };
|
||||
},
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
|
||||
await act(async () => {
|
||||
@@ -106,16 +106,16 @@ describe('useGitBranchName', () => {
|
||||
});
|
||||
|
||||
it('should return undefined if branch is HEAD and getting commit hash fails', async () => {
|
||||
(
|
||||
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
|
||||
).mockImplementation(async (command: string, args: string[]) => {
|
||||
if (args.includes('--abbrev-ref')) {
|
||||
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string };
|
||||
} else if (args.includes('--short')) {
|
||||
throw new Error('Git error');
|
||||
}
|
||||
return { stdout: '' } as { stdout: string; stderr: string };
|
||||
});
|
||||
(execCommand as Mock).mockImplementation(
|
||||
async (_command: string, args?: readonly string[] | null) => {
|
||||
if (args?.includes('--abbrev-ref')) {
|
||||
return { stdout: 'HEAD\n', stderr: '', code: 0 };
|
||||
} else if (args?.includes('--short')) {
|
||||
throw new Error('Git error');
|
||||
}
|
||||
return { stdout: '', stderr: '', code: 0 };
|
||||
},
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
|
||||
await act(async () => {
|
||||
@@ -127,14 +127,16 @@ describe('useGitBranchName', () => {
|
||||
|
||||
it('should update branch name when .git/HEAD changes', async ({ skip }) => {
|
||||
skip(); // TODO: fix
|
||||
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>)
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' } as {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
(execCommand as Mock)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'main\n',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: 'develop\n' } as {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'develop\n',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
|
||||
@@ -162,11 +164,11 @@ describe('useGitBranchName', () => {
|
||||
// Remove .git/logs/HEAD to cause an error in fs.watch setup
|
||||
vol.unlinkSync(GIT_LOGS_HEAD_PATH);
|
||||
|
||||
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
|
||||
{
|
||||
stdout: 'main\n',
|
||||
} as { stdout: string; stderr: string },
|
||||
);
|
||||
(execCommand as Mock).mockResolvedValue({
|
||||
stdout: 'main\n',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
|
||||
|
||||
@@ -177,11 +179,11 @@ describe('useGitBranchName', () => {
|
||||
|
||||
expect(result.current).toBe('main'); // Branch name should still be fetched initially
|
||||
|
||||
(
|
||||
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
|
||||
).mockResolvedValueOnce({
|
||||
(execCommand as Mock).mockResolvedValueOnce({
|
||||
stdout: 'develop\n',
|
||||
} as { stdout: string; stderr: string });
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
// This write would trigger the watcher if it was set up
|
||||
// but since it failed, the branch name should not update
|
||||
@@ -207,11 +209,11 @@ describe('useGitBranchName', () => {
|
||||
close: closeMock,
|
||||
} as unknown as ReturnType<typeof fs.watch>);
|
||||
|
||||
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
|
||||
{
|
||||
stdout: 'main\n',
|
||||
} as { stdout: string; stderr: string },
|
||||
);
|
||||
(execCommand as Mock).mockResolvedValue({
|
||||
stdout: 'main\n',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
const { unmount, rerender } = renderHook(() => useGitBranchName(CWD));
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { spawnAsync } from '@qwen-code/qwen-code-core';
|
||||
import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -15,7 +15,11 @@ export function useGitBranchName(cwd: string): string | undefined {
|
||||
|
||||
const fetchBranchName = useCallback(async () => {
|
||||
try {
|
||||
const { stdout } = await spawnAsync(
|
||||
if (!isCommandAvailable('git').available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = await execCommand(
|
||||
'git',
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
{ cwd },
|
||||
@@ -24,7 +28,7 @@ export function useGitBranchName(cwd: string): string | undefined {
|
||||
if (branch && branch !== 'HEAD') {
|
||||
setBranchName(branch);
|
||||
} else {
|
||||
const { stdout: hashStdout } = await spawnAsync(
|
||||
const { stdout: hashStdout } = await execCommand(
|
||||
'git',
|
||||
['rev-parse', '--short', 'HEAD'],
|
||||
{ cwd },
|
||||
|
||||
47
packages/cli/src/ui/hooks/useInitializationAuthError.ts
Normal file
47
packages/cli/src/ui/hooks/useInitializationAuthError.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that handles initialization authentication error only once.
|
||||
* This ensures that if an auth error occurred during app initialization,
|
||||
* it is reported to the user exactly once, even if the component re-renders.
|
||||
*
|
||||
* @param authError - The authentication error from initialization, or null if no error.
|
||||
* @param onAuthError - Callback function to handle the authentication error.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useInitializationAuthError(
|
||||
* initializationResult.authError,
|
||||
* onAuthError
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const useInitializationAuthError = (
|
||||
authError: string | null,
|
||||
onAuthError: (error: string) => void,
|
||||
): void => {
|
||||
const hasHandled = useRef(false);
|
||||
const authErrorRef = useRef(authError);
|
||||
const onAuthErrorRef = useRef(onAuthError);
|
||||
|
||||
// Update refs to always use latest values
|
||||
authErrorRef.current = authError;
|
||||
onAuthErrorRef.current = onAuthError;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHandled.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authErrorRef.current) {
|
||||
hasHandled.current = true;
|
||||
onAuthErrorRef.current(authErrorRef.current);
|
||||
}
|
||||
}, [authError, onAuthError]);
|
||||
};
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
getResponseText,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Content, GenerateContentConfig } from '@google/genai';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
|
||||
export const PROMPT_COMPLETION_MIN_LENGTH = 5;
|
||||
export const PROMPT_COMPLETION_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface PromptCompletion {
|
||||
text: string;
|
||||
isLoading: boolean;
|
||||
isActive: boolean;
|
||||
accept: () => void;
|
||||
clear: () => void;
|
||||
markSelected: (selectedText: string) => void;
|
||||
}
|
||||
|
||||
export interface UsePromptCompletionOptions {
|
||||
buffer: TextBuffer;
|
||||
config?: Config;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function usePromptCompletion({
|
||||
buffer,
|
||||
config,
|
||||
enabled,
|
||||
}: UsePromptCompletionOptions): PromptCompletion {
|
||||
const [ghostText, setGhostText] = useState<string>('');
|
||||
const [isLoadingGhostText, setIsLoadingGhostText] = useState<boolean>(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [justSelectedSuggestion, setJustSelectedSuggestion] =
|
||||
useState<boolean>(false);
|
||||
const lastSelectedTextRef = useRef<string>('');
|
||||
const lastRequestedTextRef = useRef<string>('');
|
||||
|
||||
const isPromptCompletionEnabled =
|
||||
enabled && (config?.getEnablePromptCompletion() ?? false);
|
||||
|
||||
const clearGhostText = useCallback(() => {
|
||||
setGhostText('');
|
||||
setIsLoadingGhostText(false);
|
||||
}, []);
|
||||
|
||||
const acceptGhostText = useCallback(() => {
|
||||
if (ghostText && ghostText.length > buffer.text.length) {
|
||||
buffer.setText(ghostText);
|
||||
setGhostText('');
|
||||
setJustSelectedSuggestion(true);
|
||||
lastSelectedTextRef.current = ghostText;
|
||||
}
|
||||
}, [ghostText, buffer]);
|
||||
|
||||
const markSuggestionSelected = useCallback((selectedText: string) => {
|
||||
setJustSelectedSuggestion(true);
|
||||
lastSelectedTextRef.current = selectedText;
|
||||
}, []);
|
||||
|
||||
const generatePromptSuggestions = useCallback(async () => {
|
||||
const trimmedText = buffer.text.trim();
|
||||
const geminiClient = config?.getGeminiClient();
|
||||
|
||||
if (trimmedText === lastRequestedTextRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedText.length < PROMPT_COMPLETION_MIN_LENGTH ||
|
||||
!geminiClient ||
|
||||
isSlashCommand(trimmedText) ||
|
||||
trimmedText.includes('@') ||
|
||||
!isPromptCompletionEnabled
|
||||
) {
|
||||
clearGhostText();
|
||||
lastRequestedTextRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestedTextRef.current = trimmedText;
|
||||
setIsLoadingGhostText(true);
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
||||
try {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `You are a professional prompt engineering assistant. Complete the user's partial prompt with expert precision and clarity. User's input: "${trimmedText}" Continue this prompt by adding specific, actionable details that align with the user's intent. Focus on: clear, precise language; structured requirements; professional terminology; measurable outcomes. Length Guidelines: Keep suggestions concise (ideally 10-20 characters); prioritize brevity while maintaining clarity; use essential keywords only; avoid redundant phrases. Start your response with the exact user text ("${trimmedText}") followed by your completion. Provide practical, implementation-focused suggestions rather than creative interpretations. Format: Plain text only. Single completion. Match the user's language. Emphasize conciseness over elaboration.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const generationConfig: GenerateContentConfig = {
|
||||
temperature: 0.3,
|
||||
maxOutputTokens: 16000,
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await geminiClient.generateContent(
|
||||
contents,
|
||||
generationConfig,
|
||||
signal,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
const responseText = getResponseText(response);
|
||||
|
||||
if (responseText) {
|
||||
const suggestionText = responseText.trim();
|
||||
|
||||
if (
|
||||
suggestionText.length > 0 &&
|
||||
suggestionText.startsWith(trimmedText)
|
||||
) {
|
||||
setGhostText(suggestionText);
|
||||
} else {
|
||||
clearGhostText();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!(
|
||||
signal.aborted ||
|
||||
(error instanceof Error && error.name === 'AbortError')
|
||||
)
|
||||
) {
|
||||
console.error('prompt completion error:', error);
|
||||
// Clear the last requested text to allow retry only on real errors
|
||||
lastRequestedTextRef.current = '';
|
||||
}
|
||||
clearGhostText();
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setIsLoadingGhostText(false);
|
||||
}
|
||||
}
|
||||
}, [buffer.text, config, clearGhostText, isPromptCompletionEnabled]);
|
||||
|
||||
const isCursorAtEnd = useCallback(() => {
|
||||
const [cursorRow, cursorCol] = buffer.cursor;
|
||||
const totalLines = buffer.lines.length;
|
||||
if (cursorRow !== totalLines - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastLine = buffer.lines[cursorRow] || '';
|
||||
return cursorCol === lastLine.length;
|
||||
}, [buffer.cursor, buffer.lines]);
|
||||
|
||||
const handlePromptCompletion = useCallback(() => {
|
||||
if (!isCursorAtEnd()) {
|
||||
clearGhostText();
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedText = buffer.text.trim();
|
||||
|
||||
if (justSelectedSuggestion && trimmedText === lastSelectedTextRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedText !== lastSelectedTextRef.current) {
|
||||
setJustSelectedSuggestion(false);
|
||||
lastSelectedTextRef.current = '';
|
||||
}
|
||||
|
||||
generatePromptSuggestions();
|
||||
}, [
|
||||
buffer.text,
|
||||
generatePromptSuggestions,
|
||||
justSelectedSuggestion,
|
||||
isCursorAtEnd,
|
||||
clearGhostText,
|
||||
]);
|
||||
|
||||
// Debounce prompt completion
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(
|
||||
handlePromptCompletion,
|
||||
PROMPT_COMPLETION_DEBOUNCE_MS,
|
||||
);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [buffer.text, buffer.cursor, handlePromptCompletion]);
|
||||
|
||||
// Ghost text validation - clear if it doesn't match current text or cursor not at end
|
||||
useEffect(() => {
|
||||
const currentText = buffer.text.trim();
|
||||
|
||||
if (ghostText && !isCursorAtEnd()) {
|
||||
clearGhostText();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ghostText &&
|
||||
currentText.length > 0 &&
|
||||
!ghostText.startsWith(currentText)
|
||||
) {
|
||||
clearGhostText();
|
||||
}
|
||||
}, [buffer.text, buffer.cursor, ghostText, clearGhostText, isCursorAtEnd]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => abortControllerRef.current?.abort(), []);
|
||||
|
||||
const isActive = useMemo(() => {
|
||||
if (!isPromptCompletionEnabled) return false;
|
||||
|
||||
if (!isCursorAtEnd()) return false;
|
||||
|
||||
const trimmedText = buffer.text.trim();
|
||||
return (
|
||||
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
|
||||
!isSlashCommand(trimmedText) &&
|
||||
!trimmedText.includes('@')
|
||||
);
|
||||
}, [buffer.text, isPromptCompletionEnabled, isCursorAtEnd]);
|
||||
|
||||
return {
|
||||
text: ghostText,
|
||||
isLoading: isLoadingGhostText,
|
||||
isActive,
|
||||
accept: acceptGhostText,
|
||||
clear: clearGhostText,
|
||||
markSelected: markSuggestionSelected,
|
||||
};
|
||||
}
|
||||
@@ -6,14 +6,13 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { DeviceAuthorizationInfo } from './useQwenAuth.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { useQwenAuth } from './useQwenAuth.js';
|
||||
import {
|
||||
AuthType,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock the qwenOAuth2Events
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
@@ -36,24 +35,14 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
|
||||
|
||||
describe('useQwenAuth', () => {
|
||||
const mockDeviceAuth: DeviceAuthorizationInfo = {
|
||||
const mockDeviceAuth: DeviceAuthorizationData = {
|
||||
verification_uri: 'https://oauth.qwen.com/device',
|
||||
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
|
||||
user_code: 'ABC123',
|
||||
expires_in: 1800,
|
||||
device_code: 'device_code_123',
|
||||
};
|
||||
|
||||
const createMockSettings = (authType: AuthType): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: authType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -63,36 +52,33 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should initialize with default state when not Qwen auth', () => {
|
||||
const settings = createMockSettings(AuthType.USE_GEMINI);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
||||
const { result } = renderHook(() =>
|
||||
useQwenAuth(AuthType.USE_GEMINI, false),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isQwenAuthenticating: false,
|
||||
expect(result.current.qwenAuthState).toEqual({
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
isQwenAuth: false,
|
||||
cancelQwenAuth: expect.any(Function),
|
||||
});
|
||||
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should initialize with default state when Qwen auth but not authenticating', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
||||
const { result } = renderHook(() =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, false),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isQwenAuthenticating: false,
|
||||
expect(result.current.qwenAuthState).toEqual({
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
isQwenAuth: true,
|
||||
cancelQwenAuth: expect.any(Function),
|
||||
});
|
||||
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should set up event listeners when Qwen auth and authenticating', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
renderHook(() => useQwenAuth(settings, true));
|
||||
renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
@@ -105,8 +91,7 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should handle device auth event', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -115,19 +100,17 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
});
|
||||
|
||||
it('should handle auth progress event - success', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -140,18 +123,19 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('success', 'Authentication successful!');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('success');
|
||||
expect(result.current.authMessage).toBe('Authentication successful!');
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('success');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Authentication successful!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - error', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -164,18 +148,19 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('error', 'Authentication failed');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('error');
|
||||
expect(result.current.authMessage).toBe('Authentication failed');
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('error');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Authentication failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - polling', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -188,20 +173,19 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('polling', 'Waiting for user authorization...');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.authMessage).toBe(
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Waiting for user authorization...',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - rate_limit', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -214,7 +198,7 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!(
|
||||
@@ -223,14 +207,13 @@ describe('useQwenAuth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('rate_limit');
|
||||
expect(result.current.authMessage).toBe(
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('rate_limit');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event without message', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -243,27 +226,30 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('success');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('success');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('success');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should clean up event listeners when auth type changes', () => {
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { rerender } = renderHook(
|
||||
({ settings, isAuthenticating }) =>
|
||||
useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
||||
({ pendingAuthType, isAuthenticating }) =>
|
||||
useQwenAuth(pendingAuthType, isAuthenticating),
|
||||
{
|
||||
initialProps: {
|
||||
pendingAuthType: AuthType.QWEN_OAUTH,
|
||||
isAuthenticating: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Change to non-Qwen auth
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
||||
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
|
||||
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
@@ -276,9 +262,9 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should clean up event listeners when authentication stops', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { rerender } = renderHook(
|
||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
||||
({ isAuthenticating }) =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
|
||||
{ initialProps: { isAuthenticating: true } },
|
||||
);
|
||||
|
||||
@@ -296,8 +282,9 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { unmount } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { unmount } = renderHook(() =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, true),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -312,8 +299,7 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should reset state when switching from Qwen auth to another auth type', () => {
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -323,9 +309,14 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ settings, isAuthenticating }) =>
|
||||
useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
||||
({ pendingAuthType, isAuthenticating }) =>
|
||||
useQwenAuth(pendingAuthType, isAuthenticating),
|
||||
{
|
||||
initialProps: {
|
||||
pendingAuthType: AuthType.QWEN_OAUTH,
|
||||
isAuthenticating: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Simulate device auth
|
||||
@@ -333,22 +324,19 @@ describe('useQwenAuth', () => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
|
||||
// Switch to different auth type
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
||||
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should reset state when authentication stops', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -358,7 +346,8 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
||||
({ isAuthenticating }) =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
|
||||
{ initialProps: { isAuthenticating: true } },
|
||||
);
|
||||
|
||||
@@ -367,21 +356,19 @@ describe('useQwenAuth', () => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
|
||||
// Stop authentication
|
||||
rerender({ isAuthenticating: false });
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle cancelQwenAuth function', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -390,53 +377,49 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
// Set up some state
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
|
||||
// Cancel auth
|
||||
act(() => {
|
||||
result.current.cancelQwenAuth();
|
||||
});
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should maintain isQwenAuth flag correctly', () => {
|
||||
// Test with Qwen OAuth
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
it('should handle different auth types correctly', () => {
|
||||
// Test with Qwen OAuth - should set up event listeners when authenticating
|
||||
const { result: qwenResult } = renderHook(() =>
|
||||
useQwenAuth(qwenSettings, false),
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, true),
|
||||
);
|
||||
expect(qwenResult.current.isQwenAuth).toBe(true);
|
||||
expect(qwenResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
|
||||
|
||||
// Test with other auth types
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
// Test with other auth types - should not set up event listeners
|
||||
const { result: geminiResult } = renderHook(() =>
|
||||
useQwenAuth(geminiSettings, false),
|
||||
useQwenAuth(AuthType.USE_GEMINI, true),
|
||||
);
|
||||
expect(geminiResult.current.isQwenAuth).toBe(false);
|
||||
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
|
||||
const oauthSettings = createMockSettings(AuthType.LOGIN_WITH_GOOGLE);
|
||||
const { result: oauthResult } = renderHook(() =>
|
||||
useQwenAuth(oauthSettings, false),
|
||||
useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true),
|
||||
);
|
||||
expect(oauthResult.current.isQwenAuth).toBe(false);
|
||||
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
});
|
||||
|
||||
it('should set isQwenAuthenticating to true when starting authentication with Qwen auth', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
it('should initialize with idle status when starting authentication with Qwen auth', () => {
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,23 +5,15 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import {
|
||||
AuthType,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
type DeviceAuthorizationData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface DeviceAuthorizationInfo {
|
||||
verification_uri: string;
|
||||
verification_uri_complete: string;
|
||||
user_code: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface QwenAuthState {
|
||||
isQwenAuthenticating: boolean;
|
||||
deviceAuth: DeviceAuthorizationInfo | null;
|
||||
export interface QwenAuthState {
|
||||
deviceAuth: DeviceAuthorizationData | null;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
@@ -33,25 +25,22 @@ interface QwenAuthState {
|
||||
}
|
||||
|
||||
export const useQwenAuth = (
|
||||
settings: LoadedSettings,
|
||||
pendingAuthType: AuthType | undefined,
|
||||
isAuthenticating: boolean,
|
||||
) => {
|
||||
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
});
|
||||
|
||||
const isQwenAuth =
|
||||
settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH;
|
||||
const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH;
|
||||
|
||||
// Set up event listeners when authentication starts
|
||||
useEffect(() => {
|
||||
if (!isQwenAuth || !isAuthenticating) {
|
||||
// Reset state when not authenticating or not Qwen auth
|
||||
setQwenAuthState({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
@@ -61,12 +50,11 @@ export const useQwenAuth = (
|
||||
|
||||
setQwenAuthState((prev) => ({
|
||||
...prev,
|
||||
isQwenAuthenticating: true,
|
||||
authStatus: 'idle',
|
||||
}));
|
||||
|
||||
// Set up event listeners
|
||||
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => {
|
||||
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => {
|
||||
setQwenAuthState((prev) => ({
|
||||
...prev,
|
||||
deviceAuth: {
|
||||
@@ -74,6 +62,7 @@ export const useQwenAuth = (
|
||||
verification_uri_complete: deviceAuth.verification_uri_complete,
|
||||
user_code: deviceAuth.user_code,
|
||||
expires_in: deviceAuth.expires_in,
|
||||
device_code: deviceAuth.device_code,
|
||||
},
|
||||
authStatus: 'polling',
|
||||
}));
|
||||
@@ -106,7 +95,6 @@ export const useQwenAuth = (
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
||||
|
||||
setQwenAuthState({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
@@ -114,8 +102,7 @@ export const useQwenAuth = (
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...qwenAuthState,
|
||||
isQwenAuth,
|
||||
qwenAuthState,
|
||||
cancelQwenAuth,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { spawnAsync } from '@qwen-code/qwen-code-core';
|
||||
import { execCommand } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Checks if the system clipboard contains an image (macOS only for now)
|
||||
@@ -19,7 +19,7 @@ export async function clipboardHasImage(): Promise<boolean> {
|
||||
|
||||
try {
|
||||
// Use osascript to check clipboard type
|
||||
const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']);
|
||||
const { stdout } = await execCommand('osascript', ['-e', 'clipboard info']);
|
||||
const imageRegex =
|
||||
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
|
||||
return imageRegex.test(stdout);
|
||||
@@ -80,7 +80,7 @@ export async function saveClipboardImage(
|
||||
end try
|
||||
`;
|
||||
|
||||
const { stdout } = await spawnAsync('osascript', ['-e', script]);
|
||||
const { stdout } = await execCommand('osascript', ['-e', script]);
|
||||
|
||||
if (stdout.trim() === 'success') {
|
||||
// Verify the file was created and has content
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isSlashCommand,
|
||||
copyToClipboard,
|
||||
getUrlOpenCommand,
|
||||
CodePage,
|
||||
} from './commandUtils.js';
|
||||
|
||||
// Mock child_process
|
||||
@@ -188,7 +189,10 @@ describe('commandUtils', () => {
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('clip', []);
|
||||
expect(mockSpawn).toHaveBeenCalledWith('cmd', [
|
||||
'/c',
|
||||
`chcp ${CodePage.UTF8} >nul && clip`,
|
||||
]);
|
||||
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
|
||||
expect(mockChild.stdin.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
import type { SpawnOptions } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Common Windows console code pages (CP) used for encoding conversions.
|
||||
*
|
||||
* @remarks
|
||||
* - `UTF8` (65001): Unicode (UTF-8) — recommended for cross-language scripts.
|
||||
* - `GBK` (936): Simplified Chinese — default on most Chinese Windows systems.
|
||||
* - `BIG5` (950): Traditional Chinese.
|
||||
* - `LATIN1` (1252): Western European — default on many Western systems.
|
||||
*/
|
||||
export const CodePage = {
|
||||
UTF8: 65001,
|
||||
GBK: 936,
|
||||
BIG5: 950,
|
||||
LATIN1: 1252,
|
||||
} as const;
|
||||
|
||||
export type CodePage = (typeof CodePage)[keyof typeof CodePage];
|
||||
/**
|
||||
* Checks if a query string potentially represents an '@' command.
|
||||
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
|
||||
@@ -80,7 +97,7 @@ export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return run('clip', []);
|
||||
return run('cmd', ['/c', `chcp ${CodePage.UTF8} >nul && clip`]);
|
||||
case 'darwin':
|
||||
return run('pbcopy', []);
|
||||
case 'linux':
|
||||
|
||||
72
packages/cli/src/utils/attentionNotification.test.ts
Normal file
72
packages/cli/src/utils/attentionNotification.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
notifyTerminalAttention,
|
||||
AttentionNotificationReason,
|
||||
} from './attentionNotification.js';
|
||||
|
||||
describe('notifyTerminalAttention', () => {
|
||||
let stream: { write: ReturnType<typeof vi.fn>; isTTY: boolean };
|
||||
|
||||
beforeEach(() => {
|
||||
stream = { write: vi.fn().mockReturnValue(true), isTTY: true };
|
||||
});
|
||||
|
||||
it('emits terminal bell character', () => {
|
||||
const result = notifyTerminalAttention(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(stream.write).toHaveBeenCalledWith('\u0007');
|
||||
});
|
||||
|
||||
it('returns false when not running inside a tty', () => {
|
||||
stream.isTTY = false;
|
||||
|
||||
const result = notifyTerminalAttention(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ stream },
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(stream.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when stream write fails', () => {
|
||||
stream.write = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
const result = notifyTerminalAttention(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ stream },
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('works with different notification reasons', () => {
|
||||
const reasons = [
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
];
|
||||
|
||||
reasons.forEach((reason) => {
|
||||
stream.write.mockClear();
|
||||
|
||||
const result = notifyTerminalAttention(reason, { stream });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(stream.write).toHaveBeenCalledWith('\u0007');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
packages/cli/src/utils/attentionNotification.ts
Normal file
43
packages/cli/src/utils/attentionNotification.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
export enum AttentionNotificationReason {
|
||||
ToolApproval = 'tool_approval',
|
||||
LongTaskComplete = 'long_task_complete',
|
||||
}
|
||||
|
||||
export interface TerminalNotificationOptions {
|
||||
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
|
||||
}
|
||||
|
||||
const TERMINAL_BELL = '\u0007';
|
||||
|
||||
/**
|
||||
* Grabs the user's attention by emitting the terminal bell character.
|
||||
* This causes the terminal to flash or play a sound, alerting the user
|
||||
* to check the CLI for important events.
|
||||
*
|
||||
* @returns true when the bell was successfully written to the terminal.
|
||||
*/
|
||||
export function notifyTerminalAttention(
|
||||
_reason: AttentionNotificationReason,
|
||||
options: TerminalNotificationOptions = {},
|
||||
): boolean {
|
||||
const stream = options.stream ?? process.stdout;
|
||||
if (!stream?.write || stream.isTTY === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
stream.write(TERMINAL_BELL);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to send terminal bell:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,15 @@ const ripgrepAvailabilityCheck: WarningCheck = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep);
|
||||
if (!isAvailable) {
|
||||
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.';
|
||||
try {
|
||||
const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep);
|
||||
if (!isAvailable) {
|
||||
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.';
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return `Ripgrep not available: ${error instanceof Error ? error.message : 'Unknown error'}. Falling back to built-in grep.`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -128,6 +128,14 @@ export type AgentRequest = z.infer<typeof agentRequestSchema>;
|
||||
|
||||
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
|
||||
|
||||
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
|
||||
|
||||
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
|
||||
|
||||
export type AvailableCommandsUpdate = z.infer<
|
||||
typeof availableCommandsUpdateSchema
|
||||
>;
|
||||
|
||||
export const writeTextFileRequestSchema = z.object({
|
||||
content: z.string(),
|
||||
path: z.string(),
|
||||
@@ -386,6 +394,21 @@ export const promptRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandInputSchema = z.object({
|
||||
hint: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandSchema = z.object({
|
||||
description: z.string(),
|
||||
input: availableCommandInputSchema.nullable().optional(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandsUpdateSchema = z.object({
|
||||
availableCommands: z.array(availableCommandSchema),
|
||||
sessionUpdate: z.literal('available_commands_update'),
|
||||
});
|
||||
|
||||
export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
@@ -423,6 +446,7 @@ export const sessionUpdateSchema = z.union([
|
||||
entries: z.array(planEntrySchema),
|
||||
sessionUpdate: z.literal('plan'),
|
||||
}),
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
MCPServerConfig,
|
||||
ToolConfirmationOutcome,
|
||||
logToolCall,
|
||||
logUserPrompt,
|
||||
getErrorStatus,
|
||||
isWithinRoot,
|
||||
isNodeError,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
TaskTool,
|
||||
Kind,
|
||||
TodoWriteTool,
|
||||
UserPromptEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as acp from './acp.js';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -53,6 +55,26 @@ import { ExtensionStorage, type Extension } from '../config/extension.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
|
||||
import {
|
||||
handleSlashCommand,
|
||||
getAvailableCommands,
|
||||
} from '../nonInteractiveCliCommands.js';
|
||||
import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js';
|
||||
import { isSlashCommand } from '../ui/utils/commandUtils.js';
|
||||
|
||||
/**
|
||||
* Built-in commands that are allowed in ACP integration mode.
|
||||
* Only these commands will be available when using handleSlashCommand
|
||||
* or getAvailableCommands in ACP integration.
|
||||
*
|
||||
* Currently, only "init" is supported because `handleSlashCommand` in
|
||||
* nonInteractiveCliCommands.ts only supports handling results where
|
||||
* result.type is "submit_prompt". Other result types are either coupled
|
||||
* to the UI or cannot send notifications to the client via ACP.
|
||||
*
|
||||
* If you have a good idea to add support for more commands, PRs are welcome!
|
||||
*/
|
||||
const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
||||
|
||||
/**
|
||||
* Resolves the model to use based on the current configuration.
|
||||
@@ -151,7 +173,7 @@ class GeminiAgent {
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const sessionId = this.config.getSessionId() || randomUUID();
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
let isAuthenticated = false;
|
||||
@@ -182,9 +204,20 @@ class GeminiAgent {
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
const session = new Session(sessionId, chat, config, this.client);
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.client,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Send available commands update as the first session update
|
||||
setTimeout(async () => {
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
};
|
||||
@@ -242,12 +275,14 @@ class GeminiAgent {
|
||||
|
||||
class Session {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
private turn: number = 0;
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
private readonly chat: GeminiChat,
|
||||
private readonly config: Config,
|
||||
private readonly client: acp.Client,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
@@ -264,10 +299,57 @@ class Session {
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingPrompt = pendingSend;
|
||||
|
||||
const promptId = Math.random().toString(16).slice(2);
|
||||
const chat = this.chat;
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
const chat = this.chat;
|
||||
const promptId = this.config.getSessionId() + '########' + this.turn;
|
||||
|
||||
// Extract text from all text blocks to construct the full prompt text for logging
|
||||
const promptText = params.prompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
|
||||
// Log user prompt
|
||||
logUserPrompt(
|
||||
this.config,
|
||||
new UserPromptEvent(
|
||||
promptText.length,
|
||||
promptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
promptText,
|
||||
),
|
||||
);
|
||||
|
||||
// Check if the input contains a slash command
|
||||
// Extract text from the first text block if present
|
||||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||
const inputText = firstTextBlock?.text || '';
|
||||
|
||||
let parts: Part[];
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - allow specific built-in commands for ACP integration
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
if (slashCommandResult) {
|
||||
// Use the result from the slash command
|
||||
parts = slashCommandResult as Part[];
|
||||
} else {
|
||||
// Slash command didn't return a prompt, continue with normal processing
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
} else {
|
||||
// Normal processing for non-slash commands
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
@@ -361,6 +443,37 @@ class Session {
|
||||
await this.client.sessionUpdate(params);
|
||||
}
|
||||
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const slashCommands = await getAvailableCommands(
|
||||
this.config,
|
||||
this.settings,
|
||||
abortController.signal,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||
const availableCommands: AvailableCommand[] = slashCommands.map(
|
||||
(cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
input: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const update: AvailableCommandsUpdate = {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
};
|
||||
|
||||
await this.sendUpdate(update);
|
||||
} catch (error) {
|
||||
// Log error but don't fail session creation
|
||||
console.error('Error sending available commands update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async runTool(
|
||||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
|
||||
Reference in New Issue
Block a user