Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/implement-ui-from-cc-vscode-extension

This commit is contained in:
yiliang114
2025-11-20 10:06:02 +08:00
35 changed files with 877 additions and 1188 deletions

View File

@@ -106,7 +106,7 @@ Subagents are configured using Markdown files with YAML frontmatter. This format
---
name: agent-name
description: Brief description of when and how to use this agent
tools:
tools:
- tool1
- tool2
- tool3 # Optional
@@ -170,7 +170,7 @@ Perfect for comprehensive test creation and test-driven development.
---
name: testing-expert
description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices
tools:
tools:
- read_file
- write_file
- read_many_files
@@ -214,7 +214,7 @@ Specialized in creating clear, comprehensive documentation.
---
name: documentation-writer
description: Creates comprehensive documentation, README files, API docs, and user guides
tools:
tools:
- read_file
- write_file
- read_many_files
@@ -267,7 +267,7 @@ Focused on code quality, security, and best practices.
---
name: code-reviewer
description: Reviews code for best practices, security issues, performance, and maintainability
tools:
tools:
- read_file
- read_many_files
---
@@ -311,7 +311,7 @@ Optimized for React development, hooks, and component patterns.
---
name: react-specialist
description: Expert in React development, hooks, component patterns, and modern React best practices
tools:
tools:
- read_file
- write_file
- read_many_files

View File

@@ -14,6 +14,13 @@ This guide provides solutions to common issues and debugging tips, including top
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
- **Issue: Unable to display UI after authentication failure**
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
- Open `~/.qwen/settings.json` (or `./.qwen/settings.json` for project-specific settings)
- Remove the `security.auth.selectedType` field
- Restart the CLI to allow it to prompt for authentication again
## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?**

View File

@@ -21,23 +21,21 @@ describe('Interactive Mode', () => {
it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command',
async () => {
await rig.setup('interactive-compress-test');
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(
@@ -68,49 +66,43 @@ describe('Interactive Mode', () => {
},
);
it.skipIf(process.platform === 'win32')(
'should handle compression failure on token inflation',
async () => {
await rig.setup('interactive-compress-test');
it.skip('should handle compression failure on token inflation', async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive();
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 1000));
await type(ptyProcess, '\r');
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent).toBe(true);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
const compressionFailed = await rig.waitForText(
'Nothing to compress.',
25000,
);
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent).toBe(true);
const compressionFailed = await rig.waitForText(
'compression was not beneficial',
25000,
);
expect(compressionFailed).toBe(true);
},
);
expect(compressionFailed).toBe(true);
});
});

View File

@@ -22,21 +22,19 @@ describe('Interactive file system', () => {
'should perform a read-then-write sequence in interactive mode',
async () => {
const fileName = 'version.txt';
await rig.setup('interactive-read-then-write');
await rig.setup('interactive-read-then-write', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
rig.createFile(fileName, '1.0.0');
const { ptyProcess } = rig.runInteractive();
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(

View File

@@ -839,5 +839,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
);
} catch (error) {
console.error('Error saving user settings file:', error);
throw error;
}
}

View File

@@ -8,6 +8,8 @@ import {
type AuthType,
type Config,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
/**
@@ -25,11 +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 `Failed to login. 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;

View File

@@ -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';
@@ -33,10 +33,18 @@ export async function initializeApp(
config: Config,
settings: LoadedSettings,
): Promise<InitializationResult> {
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 =

View File

@@ -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';
@@ -93,6 +91,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.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';
@@ -349,20 +348,13 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config);
// Qwen OAuth authentication state
const {
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
cancelQwenAuth,
} = useQwenAuth(settings, isAuthenticating);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config,
historyManager,
@@ -371,19 +363,7 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError,
});
// Handle Qwen OAuth timeout
const handleQwenAuthTimeout = useCallback(() => {
onAuthError('Qwen OAuth authentication timed out. Please try again.');
cancelQwenAuth();
setAuthState(AuthState.Updating);
}, [onAuthError, cancelQwenAuth, setAuthState]);
// Handle Qwen OAuth cancel
const handleQwenAuthCancel = useCallback(() => {
onAuthError('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 +375,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 &&
@@ -959,7 +941,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleApprovalModeSelect,
isAuthDialogOpen,
handleAuthSelect,
selectedAuthType: settings.merged.security?.auth?.selectedType,
pendingAuthType,
isEditorDialogOpen,
exitEditorDialog,
isSettingsDialogOpen,
@@ -1201,7 +1183,7 @@ export const AppContainer = (props: AppContainerProps) => {
isVisionSwitchDialogOpen ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
(isAuthenticating && isQwenAuthenticating) ||
isAuthenticating ||
isEditorDialogOpen ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
@@ -1224,12 +1206,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1319,12 +1298,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1418,9 +1394,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect,
setAuthState,
onAuthError,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
cancelAuthentication,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
@@ -1454,9 +1428,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect,
setAuthState,
onAuthError,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
cancelAuthentication,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,

View File

@@ -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();
});
});

View File

@@ -8,26 +8,13 @@ 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';
interface AuthDialogProps {
onSelect: (
authMethod: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
function parseDefaultAuthType(
defaultAuthType: string | undefined,
@@ -41,15 +28,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,
@@ -62,10 +48,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'],
);
@@ -73,49 +66,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('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.
@@ -129,33 +102,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
@@ -174,16 +125,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}>(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>Terms of Services and Privacy Notice for Qwen Code</Text>
</Box>

View File

@@ -6,27 +6,19 @@
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 {
AuthType,
clearCachedCredentialFile,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
): string | null {
const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
}
if (settings.merged.security?.auth?.useExternal) {
return null;
}
return validateAuthMethod(authType);
}
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const unAuthenticated =
@@ -40,6 +32,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) => {
@@ -52,90 +52,123 @@ 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);
// Log authentication failure
if (pendingAuthType) {
const authEvent = new AuthEvent(
pendingAuthType,
'manual',
'error',
errorMessage,
);
logAuth(config, authEvent);
}
},
[onAuthError, pendingAuthType, config],
);
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(`Failed to login. Message: ${getErrorMessage(e)}`);
} finally {
setIsAuthenticating(false);
}
};
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);
},
[settings, config],
[settings, handleAuthFailure, config],
);
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(() => {
@@ -143,8 +176,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,
@@ -153,6 +223,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,

View File

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

View File

@@ -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{' '}

View File

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

View File

@@ -11,13 +11,13 @@ 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';
interface QwenOAuthProgressProps {
onTimeout: () => void;
onCancel: () => void;
deviceAuth?: DeviceAuthorizationInfo;
deviceAuth?: DeviceAuthorizationData;
authStatus?:
| 'idle'
| 'polling'
@@ -131,8 +131,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();
@@ -234,6 +234,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 (

View File

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

View File

@@ -47,7 +47,7 @@ export function CompressionMessage({
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.';
case CompressionStatus.NOOP:
return 'Chat history is already compressed.';
return 'Nothing to compress.';
default:
return '';
}

View File

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

View File

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

View File

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

View 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]);
};

View File

@@ -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();
});
});

View File

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

View File

@@ -30,6 +30,7 @@ export {
logExtensionEnable,
logIdeConnection,
logExtensionDisable,
logAuth,
} from './src/telemetry/loggers.js';
export {
@@ -40,6 +41,7 @@ export {
ExtensionEnableEvent,
ExtensionUninstallEvent,
ModelSlashCommandEvent,
AuthEvent,
} from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js';
export * from './src/utils/pathReader.js';

View File

@@ -562,7 +562,7 @@ export class Config {
}
}
async refreshAuth(authMethod: AuthType) {
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
// Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them
if (
@@ -582,6 +582,7 @@ export class Config {
newContentGeneratorConfig,
this,
this.getSessionId(),
isInitialAuth,
);
// Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig;

View File

@@ -120,6 +120,7 @@ export async function createContentGenerator(
config: ContentGeneratorConfig,
gcConfig: Config,
sessionId?: string,
isInitialAuth?: boolean,
): Promise<ContentGenerator> {
const version = process.env['CLI_VERSION'] || process.version;
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
@@ -191,13 +192,17 @@ export async function createContentGenerator(
try {
// Get the Qwen OAuth client (now includes integrated token management)
const qwenClient = await getQwenOauthClient(gcConfig);
// If this is initial auth, require cached credentials to detect missing credentials
const qwenClient = await getQwenOauthClient(
gcConfig,
isInitialAuth ? { requireCachedCredentials: true } : undefined,
);
// Create the content generator with dynamic token management
return new QwenContentGenerator(qwenClient, config, gcConfig);
} catch (error) {
throw new Error(
`Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`,
`${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -825,7 +825,7 @@ describe('getQwenOAuthClient', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -983,7 +983,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1032,7 +1032,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication timed out');
).rejects.toThrow('Authorization timeout, please restart the process.');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1082,7 +1082,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow(
'Too many request for Qwen OAuth authentication, please try again later.',
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
);
SharedTokenManager.getInstance = originalGetInstance;
@@ -1119,7 +1119,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1177,7 +1177,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1264,7 +1264,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow(
'Device code expired or invalid, please restart the authorization process.',
);
SharedTokenManager.getInstance = originalGetInstance;
});

View File

@@ -467,6 +467,7 @@ export type AuthResult =
| {
success: false;
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
message?: string; // Detailed error message for better error reporting
};
/**
@@ -476,6 +477,7 @@ export const qwenOAuth2Events = new EventEmitter();
export async function getQwenOAuthClient(
config: Config,
options?: { requireCachedCredentials?: boolean },
): Promise<QwenOAuth2Client> {
const client = new QwenOAuth2Client();
@@ -488,11 +490,6 @@ export async function getQwenOAuthClient(
client.setCredentials(credentials);
return client;
} catch (error: unknown) {
console.debug(
'Shared token manager failed, attempting device flow:',
error,
);
// Handle specific token manager errors
if (error instanceof TokenManagerError) {
switch (error.type) {
@@ -520,12 +517,20 @@ export async function getQwenOAuthClient(
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
throw new Error('Qwen OAuth authentication failed');
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
}
return client;
}
// No cached credentials, use device authorization flow for authentication
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -538,20 +543,24 @@ export async function getQwenOAuthClient(
);
}
// Throw error with appropriate message based on failure reason
switch (result.reason) {
case 'timeout':
throw new Error('Qwen OAuth authentication timed out');
case 'cancelled':
throw new Error('Qwen OAuth authentication was cancelled by user');
case 'rate_limit':
throw new Error(
'Too many request for Qwen OAuth authentication, please try again later.',
);
case 'error':
default:
throw new Error('Qwen OAuth authentication failed');
}
// Use detailed error message if available, otherwise use default based on reason
const errorMessage =
result.message ||
(() => {
switch (result.reason) {
case 'timeout':
return 'Qwen OAuth authentication timed out';
case 'cancelled':
return 'Qwen OAuth authentication was cancelled by user';
case 'rate_limit':
return 'Too many request for Qwen OAuth authentication, please try again later.';
case 'error':
default:
return 'Qwen OAuth authentication failed';
}
})();
throw new Error(errorMessage);
}
return client;
@@ -644,13 +653,10 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled
if (isCancelled) {
console.debug('\nAuthentication cancelled by user.');
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
'Authentication cancelled by user.',
);
return { success: false, reason: 'cancelled' };
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'cancelled', message };
}
try {
@@ -738,13 +744,14 @@ async function authWithQwenDeviceFlow(
// Check for cancellation after waiting
if (isCancelled) {
console.debug('\nAuthentication cancelled by user.');
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
'Authentication cancelled by user.',
message,
);
return { success: false, reason: 'cancelled' };
return { success: false, reason: 'cancelled', message };
}
continue;
@@ -758,7 +765,7 @@ async function authWithQwenDeviceFlow(
);
}
} catch (error: unknown) {
// Handle specific error cases
// Extract error information
const errorMessage =
error instanceof Error ? error.message : String(error);
const statusCode =
@@ -766,42 +773,49 @@ async function authWithQwenDeviceFlow(
? (error as Error & { status?: number }).status
: null;
if (errorMessage.includes('401') || statusCode === 401) {
const message =
'Device code expired or invalid, please restart the authorization process.';
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'error' };
}
// Handle 429 Too Many Requests error
if (errorMessage.includes('429') || statusCode === 429) {
const message =
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.';
// Emit rate limit event to notify user
// Helper function to handle error and stop polling
const handleError = (
reason: 'error' | 'rate_limit',
message: string,
eventType: 'error' | 'rate_limit' = 'error',
): AuthResult => {
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'rate_limit',
eventType,
message,
);
console.error('\n' + message);
return { success: false, reason, message };
};
console.log('\n' + message);
// Handle credential caching failures - stop polling immediately
if (errorMessage.includes('Failed to cache credentials')) {
return handleError('error', errorMessage);
}
// Return false to stop polling and go back to auth selection
return { success: false, reason: 'rate_limit' };
// Handle 401 Unauthorized - device code expired or invalid
if (errorMessage.includes('401') || statusCode === 401) {
return handleError(
'error',
'Device code expired or invalid, please restart the authorization process.',
);
}
// Handle 429 Too Many Requests - rate limiting
if (errorMessage.includes('429') || statusCode === 429) {
return handleError(
'rate_limit',
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
'rate_limit',
);
}
const message = `Error polling for token: ${errorMessage}`;
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
// Check for cancellation before waiting
if (isCancelled) {
return { success: false, reason: 'cancelled' };
const message = 'Authentication cancelled by user.';
return { success: false, reason: 'cancelled', message };
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
@@ -818,11 +832,12 @@ async function authWithQwenDeviceFlow(
);
console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout' };
return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Device authorization flow failed:', errorMessage);
return { success: false, reason: 'error' };
const message = `Device authorization flow failed: ${errorMessage}`;
console.error(message);
return { success: false, reason: 'error', message };
} finally {
// Clean up event listener
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
@@ -852,10 +867,30 @@ async function loadCachedQwenCredentials(
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString);
const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString);
} catch (error: unknown) {
// Handle file system errors (e.g., EACCES permission denied)
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCode =
error instanceof Error && 'code' in error
? (error as Error & { code?: string }).code
: undefined;
if (errorCode === 'EACCES') {
throw new Error(
`Failed to cache credentials: Permission denied (EACCES). Current user has no permission to access \`${filePath}\`. Please check permissions.`,
);
}
// Throw error for other file system failures
throw new Error(
`Failed to cache credentials: error when creating folder \`${path.dirname(filePath)}\` and writing to \`${filePath}\`. ${errorMessage}. Please check permissions.`,
);
}
}
/**

View File

@@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE =
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_AUTH = 'qwen-code.auth';
// Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -43,6 +43,7 @@ export {
logExtensionUninstall,
logRipgrepFallback,
logNextSpeakerCheck,
logAuth,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
@@ -61,6 +62,7 @@ export {
ToolOutputTruncatedEvent,
RipgrepFallbackEvent,
NextSpeakerCheckEvent,
AuthEvent,
} from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js';

View File

@@ -37,6 +37,7 @@ import {
EVENT_SUBAGENT_EXECUTION,
EVENT_MALFORMED_JSON_RESPONSE,
EVENT_INVALID_CHUNK,
EVENT_AUTH,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -83,6 +84,7 @@ import type {
SubagentExecutionEvent,
MalformedJsonResponseEvent,
InvalidChunkEvent,
AuthEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -838,3 +840,29 @@ export function logExtensionDisable(
};
logger.emit(logRecord);
}
export function logAuth(config: Config, event: AuthEvent): void {
QwenLogger.getInstance(config)?.logAuthEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_AUTH,
'event.timestamp': new Date().toISOString(),
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
attributes['error.message'] = event.error_message;
}
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Auth event: ${event.action_type} ${event.status} for ${event.auth_type}`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -37,6 +37,7 @@ import type {
ExtensionEnableEvent,
ModelSlashCommandEvent,
ExtensionDisableEvent,
AuthEvent,
} from '../types.js';
import { EndSessionEvent } from '../types.js';
import type {
@@ -746,6 +747,25 @@ export class QwenLogger {
this.flushIfNeeded();
}
logAuthEvent(event: AuthEvent): void {
const snapshots: Record<string, unknown> = {
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
snapshots['error_message'] = event.error_message;
}
const rumEvent = this.createActionEvent('auth', 'auth', {
snapshots: JSON.stringify(snapshots),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
// misc events
logFlashFallbackEvent(event: FlashFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'flash_fallback', {

View File

@@ -686,6 +686,29 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent {
}
}
export class AuthEvent implements BaseTelemetryEvent {
'event.name': 'auth';
'event.timestamp': string;
auth_type: AuthType;
action_type: 'auto' | 'manual';
status: 'success' | 'error' | 'cancelled';
error_message?: string;
constructor(
auth_type: AuthType,
action_type: 'auto' | 'manual',
status: 'success' | 'error' | 'cancelled',
error_message?: string,
) {
this['event.name'] = 'auth';
this['event.timestamp'] = new Date().toISOString();
this.auth_type = auth_type;
this.action_type = action_type;
this.status = status;
this.error_message = error_message;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -713,7 +736,8 @@ export type TelemetryEvent =
| ExtensionInstallEvent
| ExtensionUninstallEvent
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent;
| ModelSlashCommandEvent
| AuthEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';

View File

@@ -1,315 +0,0 @@
# 自动锁定编辑器组功能实现
## 概述
参考 Claude Code 的实现Qwen Code VSCode 扩展现在支持自动锁定编辑器组功能,确保 AI 助手界面保持稳定,不会被其他编辑器替换或意外关闭。
## 实现原理
### 1. VSCode 锁定组机制
**VSCode 源码分析**`src/vs/workbench/browser/parts/editor/editor.contribution.ts:558-566`
```typescript
// Lock Group: only on auxiliary window and when group is unlocked
appendEditorToolItem(
{
id: LOCK_GROUP_COMMAND_ID,
title: localize('lockEditorGroup', 'Lock Group'),
icon: Codicon.unlock,
},
ContextKeyExpr.and(
IsAuxiliaryEditorPartContext,
ActiveEditorGroupLockedContext.toNegated(),
),
CLOSE_ORDER - 1, // immediately to the left of close action
);
```
**关键条件**
- `IsAuxiliaryEditorPartContext`: 当前是辅助窗口的编辑器组
- `ActiveEditorGroupLockedContext.toNegated()`: 当前组未锁定
### 2. Claude Code 的实现方式
Claude Code 在创建 webview panel 时会检测是否在新列中打开:
```typescript
context.subscriptions.push(
vscode.commands.registerCommand(
'claude-vscode.editor.open',
async (param1, param2) => {
context.globalState.update('lastClaudeLocation', 1);
let { startedInNewColumn } = webviewProvider.createPanel(param1, param2);
// 如果在新列中打开,则自动锁定编辑器组
if (startedInNewColumn) {
await vscode.commands.executeCommand(
'workbench.action.lockEditorGroup',
);
}
},
),
);
```
### 3. Qwen Code 的实现
**文件位置**: `packages/vscode-ide-companion/src/WebViewProvider.ts:101-153`
```typescript
async show(): Promise<void> {
// Track if we're creating a new panel in a new column
let startedInNewColumn = false;
if (this.panel) {
// If panel already exists, just reveal it (no lock needed)
this.revealPanelTab(true);
this.capturePanelTab();
return;
}
// Mark that we're creating a new panel
startedInNewColumn = true;
this.panel = vscode.window.createWebviewPanel(
'qwenCode.chat',
'Qwen Code Chat',
{
viewColumn: vscode.ViewColumn.Beside, // Open on right side of active editor
preserveFocus: true, // Don't steal focus from editor
},
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'dist')],
},
);
// Capture the Tab that corresponds to our WebviewPanel (Claude-style)
this.capturePanelTab();
// Auto-lock editor group when opened in new column (Claude Code style)
if (startedInNewColumn) {
console.log('[WebViewProvider] Auto-locking editor group for Qwen Code chat');
try {
// Reveal panel without preserving focus to make it the active group
// This ensures the lock command targets the correct editor group
this.revealPanelTab(false);
await vscode.commands.executeCommand('workbench.action.lockEditorGroup');
console.log('[WebViewProvider] Editor group locked successfully');
} catch (error) {
console.warn('[WebViewProvider] Failed to lock editor group:', error);
// Non-fatal error, continue anyway
}
} else {
// For existing panel, reveal with preserving focus
this.revealPanelTab(true);
}
// Continue with panel setup...
}
```
### 关键修复preserveFocus 问题
**问题发现**
- 最初实现中,`createWebviewPanel` 使用了 `preserveFocus: true`
- 这导致焦点保留在左边的编辑器组,左边的组仍然是**活动组activeGroup**
- 执行 `workbench.action.lockEditorGroup` 时,命令默认作用于活动组
- 结果:**错误地锁定了左边的编辑器组**,而不是 webview 所在的组
**错误的执行流程**
```
1. createWebviewPanel() 创建新组
└─> preserveFocus: true 保持焦点在左边
└─> activeGroup 仍然是左边的编辑器组
2. executeCommand("workbench.action.lockEditorGroup")
└─> resolveCommandsContext() 使用 activeGroup
└─> activeGroup = 左边的编辑器组 ❌
└─> 错误地锁定了左边的组
```
**修复方案**
1. 在执行锁定命令之前,调用 `this.revealPanelTab(false)`
2. 这会让 webview panel 获得焦点并成为活动组
3. 然后执行锁定命令就会锁定正确的组
**修复后的执行流程**
```
1. createWebviewPanel() 创建新组
└─> preserveFocus: true 保持焦点在左边
2. revealPanelTab(false) 激活 webview 组
└─> webview 组成为 activeGroup
3. executeCommand("workbench.action.lockEditorGroup")
└─> resolveCommandsContext() 使用 activeGroup
└─> activeGroup = webview 所在的组 ✓
└─> 正确锁定 webview 所在的组
```
## 执行流程
```
1. 用户打开 Qwen Code chat
2. 调用 WebViewProvider.show()
3. 检查是否已有 panel
- 有:直接 reveal不执行锁定
- 无:创建新 panel设置 startedInNewColumn = true
4. 创建 webview panel
- viewColumn: ViewColumn.Beside
- preserveFocus: true (不抢夺焦点,保持在编辑器)
5. 捕获 Tab 引用
- 调用 capturePanelTab() 保存 Tab 对象
6. 执行自动锁定startedInNewColumn === true
- 调用 revealPanelTab(false) 激活 webview 组
- webview 所在的组成为活动组activeGroup
- 执行命令: workbench.action.lockEditorGroup
- 命令作用于活动组,正确锁定 webview 组
7. 编辑器组被锁定
- ActiveEditorGroupLockedContext 变为 true
- 工具栏显示"解锁组"按钮(锁定图标)
- webview 保持在固定位置
```
## 功能效果
### 锁定前
- ❌ 用户可以拖拽 Qwen Code panel 到其他位置
- ❌ 其他编辑器可能替换 Qwen Code panel
- ❌ 容易意外关闭整个编辑器组
### 锁定后
- ✅ Qwen Code panel 保持在固定位置
- ✅ 编辑器组不会被其他操作影响
- ✅ 工具栏显示"锁定组"按钮,用户可以手动解锁
- ✅ 类似侧边栏的稳定行为
## 设计优势
1. **防止意外操作**
- 锁定后用户不能轻易拖拽或关闭 AI 助手界面
- 减少误操作导致的工作流中断
2. **保持固定位置**
- AI 助手界面始终在用户期望的位置
- 符合"AI 助手作为辅助工具"的定位
3. **用户可控**
- 自动锁定提供默认保护
- 用户仍可以通过工具栏解锁按钮手动解锁
- 平衡了便利性和灵活性
4. **一致的用户体验**
- 与 Claude Code 保持一致的交互模式
- 用户无需学习新的行为模式
## 错误处理
```typescript
try {
await vscode.commands.executeCommand('workbench.action.lockEditorGroup');
console.log('[WebViewProvider] Editor group locked successfully');
} catch (error) {
console.warn('[WebViewProvider] Failed to lock editor group:', error);
// Non-fatal error, continue anyway
}
```
**设计考虑**
- 锁定失败不影响 panel 的正常功能
- 记录警告日志便于调试
- 优雅降级,不中断用户工作流
## 配置选项(可选扩展)
如果需要让用户控制是否自动锁定,可以添加配置项:
```typescript
// 在 package.json 中添加配置
"qwenCode.autoLockEditorGroup": {
"type": "boolean",
"default": true,
"description": "Automatically lock the editor group when opening Qwen Code chat"
}
// 在代码中检查配置
const config = vscode.workspace.getConfiguration('qwenCode');
const autoLock = config.get<boolean>('autoLockEditorGroup', true);
if (startedInNewColumn && autoLock) {
await vscode.commands.executeCommand('workbench.action.lockEditorGroup');
}
```
## 测试场景
### 场景 1: 首次打开 Qwen Code
1. 打开 VSCode没有 Qwen Code panel
2. 执行命令打开 Qwen Code chat
3. **预期**: Panel 在新列中打开,编辑器组自动锁定
### 场景 2: 已有 Qwen Code panel
1. Qwen Code panel 已打开
2. 切换到其他编辑器
3. 再次打开 Qwen Code chat
4. **预期**: Panel 被 reveal不重复锁定
### 场景 3: 手动解锁后
1. Qwen Code panel 已锁定
2. 用户点击工具栏解锁按钮
3. 编辑器组被解锁
4. **预期**: 用户可以自由操作编辑器组
### 场景 4: 关闭并重新打开
1. Qwen Code panel 已打开并锁定
2. 用户关闭 panel
3. 再次打开 Qwen Code chat
4. **预期**: 新 panel 在新列打开,自动锁定
## 兼容性
- ✅ VSCode 1.85+(支持 `workbench.action.lockEditorGroup` 命令)
- ✅ 所有操作系统Windows, macOS, Linux
- ✅ 不影响现有功能
- ✅ 向后兼容旧版本 VSCode锁定失败时优雅降级
## 相关 VSCode 命令
| 命令 | 功能 |
| ---------------------------------------- | -------------------- |
| `workbench.action.lockEditorGroup` | 锁定当前编辑器组 |
| `workbench.action.unlockEditorGroup` | 解锁当前编辑器组 |
| `workbench.action.toggleEditorGroupLock` | 切换编辑器组锁定状态 |
## 总结
通过模仿 Claude Code 的实现Qwen Code 现在提供了:
1. ✅ 自动锁定编辑器组功能
2. ✅ 与 Claude Code 一致的用户体验
3. ✅ 稳定的 AI 助手界面位置
4. ✅ 优雅的错误处理
这个功能显著提升了用户体验,让 AI 助手界面更加稳定可靠!

View File

@@ -1,290 +0,0 @@
# Claude Code 样式提取与应用
本文档记录了从 Claude Code 扩展 (v2.0.43) 编译产物中提取的样式,并应用到我们的 VSCode IDE Companion 项目中。
## 提取来源
- **路径**: `/Users/jinjing/Downloads/Anthropic.claude-code-2.0.43/extension/webview/index.css`
- **版本**: 2.0.43
- **文件类型**: 编译后的压缩 CSS
## 提取的核心样式类
### 1. Header 样式 (`.he`)
```css
.he {
display: flex;
border-bottom: 1px solid var(--app-primary-border-color);
padding: 6px 10px;
gap: 4px;
background-color: var(--app-header-background);
justify-content: flex-start;
user-select: none;
}
```
**应用到**: `.chat-header`
**改进点**:
- `gap: 4px` - 更紧凑的间距
- `justify-content: flex-start` - 左对齐而非 space-between
- `background-color: var(--app-header-background)` - 使用独立的 header 背景变量
### 2. Session Selector 按钮 (`.E`)
```css
.E {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
outline: none;
min-width: 0;
max-width: 300px;
overflow: hidden;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
}
.E:focus,
.E:hover {
background: var(--app-ghost-button-hover-background);
}
```
**应用到**: `.session-selector-dropdown select`
**改进点**:
- `background: transparent` - 默认透明背景
- `gap: 6px` - 内部元素间距
- `min-width: 0; max-width: 300px` - 响应式宽度控制
- `overflow: hidden` - 处理文本溢出
### 3. 图标按钮 (`.j`)
```css
.j {
flex: 0 0 auto;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
outline: none;
width: 24px;
height: 24px;
}
.j:focus,
.j:hover {
background: var(--app-ghost-button-hover-background);
}
```
**应用到**: `.new-session-header-button`
**改进点**:
- `flex: 0 0 auto` - 固定尺寸不伸缩
- `border: 1px solid transparent` - 保留边框空间但透明
- 精确的 `24px × 24px` 尺寸
### 4. Session Selector 弹窗 (`.Wt`)
```css
.Wt {
position: fixed;
background: var(--app-menu-background);
border: 1px solid var(--app-menu-border);
border-radius: var(--corner-radius-small);
width: min(400px, calc(100vw - 32px));
max-height: min(500px, 50vh);
display: flex;
flex-direction: column;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 1000;
outline: none;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
}
```
**应用到**: `.session-selector`
**关键特性**:
- `width: min(400px, calc(100vw - 32px))` - 响应式宽度,小屏幕自适应
- `max-height: min(500px, 50vh)` - 响应式高度
- `box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1)` - 柔和阴影
- 使用 menu 相关的 CSS 变量
### 5. Session List (`.It`, `.St`, `.s`)
```css
/* Content area */
.It {
padding: 8px;
overflow-y: auto;
flex: 1;
user-select: none;
}
/* List container */
.St {
display: flex;
flex-direction: column;
padding: var(--app-list-padding);
gap: var(--app-list-gap);
}
/* List item */
.s {
display: flex;
align-items: center;
padding: var(--app-list-item-padding);
justify-content: space-between;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
width: 100%;
font-size: inherit;
font-family: inherit;
}
.s:hover {
background: var(--app-list-hover-background);
}
.s.U {
background: var(--app-list-active-background);
color: var(--app-list-active-foreground);
}
```
**应用到**: `.session-list`, `.session-item`
**改进点**:
- `border-radius: 6px` - 圆角列表项
- `user-select: none` - 禁止选择文本
- 使用统一的 list 变量系统
## 新增 CSS 变量
从 Claude Code 中提取并添加的 CSS 变量:
```css
/* Header */
--app-header-background: var(--vscode-sideBar-background);
/* List Styles */
--app-list-padding: 0px;
--app-list-item-padding: 4px 8px;
--app-list-border-color: transparent;
--app-list-border-radius: 4px;
--app-list-hover-background: var(--vscode-list-hoverBackground);
--app-list-active-background: var(--vscode-list-activeSelectionBackground);
--app-list-active-foreground: var(--vscode-list-activeSelectionForeground);
--app-list-gap: 2px;
/* Menu Colors */
--app-menu-background: var(--vscode-menu-background);
--app-menu-border: var(--vscode-menu-border);
--app-menu-foreground: var(--vscode-menu-foreground);
--app-menu-selection-background: var(--vscode-menu-selectionBackground);
--app-menu-selection-foreground: var(--vscode-menu-selectionForeground);
/* Ghost Button */
--app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground);
```
## 设计理念总结
通过分析 Claude Code 的样式,我们发现以下设计理念:
### 1. **响应式优先**
- 使用 `min()` 函数实现响应式尺寸
- 如: `width: min(400px, calc(100vw - 32px))`
### 2. **一致的间距系统**
- 小间距: 4px
- 中间距: 8px
- 大间距: 12px, 16px
### 3. **柔和的视觉效果**
- 透明背景 + hover 时显示背景色
- 柔和的阴影: `0 4px 16px rgba(0, 0, 0, 0.1)`
- 圆角统一使用变量: `var(--corner-radius-small)` = 4px
### 4. **完整的变量系统**
- 所有颜色都通过 CSS 变量定义
- 支持 VSCode 主题自动适配
- 有合理的 fallback 值
### 5. **交互反馈清晰**
- `:hover``:focus` 状态使用相同样式
- 使用 `var(--app-ghost-button-hover-background)` 统一 hover 背景
## 文件变更
### 修改的文件
1. **`src/webview/App.css`**
- 更新 Header 样式
- 更新 Session Selector Modal 样式
- 添加新的 CSS 变量
### 新增的文件
1. **`src/webview/ClaudeCodeStyles.css`**
- 完整的 Claude Code 样式提取
- 包含详细注释和类名映射
2. **`CLAUDE_CODE_STYLES.md`**
- 本文档,记录样式提取和应用过程
## 效果对比
### 之前
- Header 使用 `justify-content: space-between`
- Session selector 宽度固定 80%
- 阴影较重: `rgba(0, 0, 0, 0.3)`
- 间距不够紧凑
### 之后
- Header 使用 `justify-content: flex-start`,间距 4px
- Session selector 响应式宽度 `min(400px, calc(100vw - 32px))`
- 柔和阴影: `rgba(0, 0, 0, 0.1)`
- 更紧凑的布局,更接近 Claude Code 的视觉风格
## 下一步优化建议
1. **添加选中状态图标** (`.ne` check icon)
2. **实现 session list 的分组显示** (`.te` group header)
3. **添加 session selector button 的图标和箭头** (`.xe`, `.fe`, `.ve` 等)
4. **考虑添加 session 数量徽章**
5. **优化移动端适配**
## 参考资料
- Claude Code Extension: https://marketplace.visualstudio.com/items?itemName=Anthropic.claude-code
- 源文件位置: `/Users/jinjing/Downloads/Anthropic.claude-code-2.0.43/extension/webview/index.css`