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

@@ -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. - **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` - 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) ## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?** - **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')( it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command', 'should trigger chat compression with /compress command',
async () => { async () => {
await rig.setup('interactive-compress-test'); await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive(); const { ptyProcess } = rig.runInteractive();
let fullOutput = ''; let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data)); 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 // Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000); const isReady = await rig.waitForText('Type your message', 15000);
expect( expect(
@@ -68,35 +66,30 @@ describe('Interactive Mode', () => {
}, },
); );
it.skipIf(process.platform === 'win32')( it.skip('should handle compression failure on token inflation', async () => {
'should handle compression failure on token inflation', await rig.setup('interactive-compress-test', {
async () => { settings: {
await rig.setup('interactive-compress-test'); security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive(); const { ptyProcess } = rig.runInteractive();
let fullOutput = ''; let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data)); 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 // Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000); const isReady = await rig.waitForText('Type your message', 25000);
expect( expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
isReady, true,
'CLI did not start up in interactive mode correctly', );
).toBe(true);
await type(ptyProcess, '/compress'); await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 1000));
await type(ptyProcess, '\r'); await type(ptyProcess, '\r');
const foundEvent = await rig.waitForTelemetryEvent( const foundEvent = await rig.waitForTelemetryEvent(
@@ -106,11 +99,10 @@ describe('Interactive Mode', () => {
expect(foundEvent).toBe(true); expect(foundEvent).toBe(true);
const compressionFailed = await rig.waitForText( const compressionFailed = await rig.waitForText(
'compression was not beneficial', 'Nothing to compress.',
25000, 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', 'should perform a read-then-write sequence in interactive mode',
async () => { async () => {
const fileName = 'version.txt'; 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'); rig.createFile(fileName, '1.0.0');
const { ptyProcess } = rig.runInteractive(); 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 // Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000); const isReady = await rig.waitForText('Type your message', 15000);
expect( expect(

View File

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

View File

@@ -8,6 +8,8 @@ import {
type AuthType, type AuthType,
type Config, type Config,
getErrorMessage, getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
/** /**
@@ -25,11 +27,21 @@ export async function performInitialAuth(
} }
try { try {
await config.refreshAuth(authType); await config.refreshAuth(authType, true);
// The console.log is intentionally left out here. // The console.log is intentionally left out here.
// We can add a dedicated startup message later if needed. // 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) { } 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; return null;

View File

@@ -11,7 +11,7 @@ import {
logIdeConnection, logIdeConnection,
type Config, type Config,
} from '@qwen-code/qwen-code-core'; } 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 { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js'; import { validateTheme } from './theme.js';
@@ -33,10 +33,18 @@ export async function initializeApp(
config: Config, config: Config,
settings: LoadedSettings, settings: LoadedSettings,
): Promise<InitializationResult> { ): Promise<InitializationResult> {
const authError = await performInitialAuth( const authType = settings.merged.security?.auth?.selectedType;
config, const authError = await performInitialAuth(config, authType);
settings.merged.security?.auth?.selectedType,
// Fallback to user select when initial authentication fails
if (authError) {
settings.setValue(
SettingScope.User,
'security.auth.selectedType',
undefined,
); );
}
const themeError = validateTheme(settings); const themeError = validateTheme(settings);
const shouldOpenAuthDialog = const shouldOpenAuthDialog =

View File

@@ -25,7 +25,6 @@ import {
type HistoryItem, type HistoryItem,
ToolCallStatus, ToolCallStatus,
type HistoryItemWithoutId, type HistoryItemWithoutId,
AuthState,
} from './types.js'; } from './types.js';
import { MessageType, StreamingState } from './types.js'; import { MessageType, StreamingState } from './types.js';
import { import {
@@ -48,7 +47,6 @@ import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js'; import { useAuthCommand } from './auth/useAuth.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -93,6 +91,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js'; import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
@@ -349,20 +348,13 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError, onAuthError,
isAuthDialogOpen, isAuthDialogOpen,
isAuthenticating, isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect, handleAuthSelect,
openAuthDialog, openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config); } = useAuthCommand(settings, config);
// Qwen OAuth authentication state
const {
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
cancelQwenAuth,
} = useQwenAuth(settings, isAuthenticating);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config, config,
historyManager, historyManager,
@@ -371,19 +363,7 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
}); });
// Handle Qwen OAuth timeout useInitializationAuthError(initializationResult.authError, onAuthError);
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]);
// Sync user tier from config when authentication changes // Sync user tier from config when authentication changes
// TODO: Implement getUserTier() method on Config if needed // TODO: Implement getUserTier() method on Config if needed
@@ -395,6 +375,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch // Check for enforced auth type mismatch
useEffect(() => { useEffect(() => {
// Check for initialization error first
if ( if (
settings.merged.security?.auth?.enforcedType && settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType && settings.merged.security?.auth.selectedType &&
@@ -959,7 +941,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleApprovalModeSelect, handleApprovalModeSelect,
isAuthDialogOpen, isAuthDialogOpen,
handleAuthSelect, handleAuthSelect,
selectedAuthType: settings.merged.security?.auth?.selectedType, pendingAuthType,
isEditorDialogOpen, isEditorDialogOpen,
exitEditorDialog, exitEditorDialog,
isSettingsDialogOpen, isSettingsDialogOpen,
@@ -1201,7 +1183,7 @@ export const AppContainer = (props: AppContainerProps) => {
isVisionSwitchDialogOpen || isVisionSwitchDialogOpen ||
isPermissionsDialogOpen || isPermissionsDialogOpen ||
isAuthDialogOpen || isAuthDialogOpen ||
(isAuthenticating && isQwenAuthenticating) || isAuthenticating ||
isEditorDialogOpen || isEditorDialogOpen ||
showIdeRestartPrompt || showIdeRestartPrompt ||
!!proQuotaRequest || !!proQuotaRequest ||
@@ -1224,12 +1206,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized, isConfigInitialized,
authError, authError,
isAuthDialogOpen, isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state // Qwen OAuth state
isQwenAuth, qwenAuthState,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError, editorError,
isEditorDialogOpen, isEditorDialogOpen,
corgiMode, corgiMode,
@@ -1319,12 +1298,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized, isConfigInitialized,
authError, authError,
isAuthDialogOpen, isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state // Qwen OAuth state
isQwenAuth, qwenAuthState,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError, editorError,
isEditorDialogOpen, isEditorDialogOpen,
corgiMode, corgiMode,
@@ -1418,9 +1394,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect, handleAuthSelect,
setAuthState, setAuthState,
onAuthError, onAuthError,
// Qwen OAuth handlers cancelAuthentication,
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect, handleEditorSelect,
exitEditorDialog, exitEditorDialog,
closeSettingsDialog, closeSettingsDialog,
@@ -1454,9 +1428,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect, handleAuthSelect,
setAuthState, setAuthState,
onAuthError, onAuthError,
// Qwen OAuth handlers cancelAuthentication,
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect, handleEditorSelect,
exitEditorDialog, exitEditorDialog,
closeSettingsDialog, closeSettingsDialog,

View File

@@ -9,6 +9,53 @@ import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js'; 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', () => { describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -66,13 +113,9 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings, {
<AuthDialog authError: 'GEMINI_API_KEY environment variable not found',
onSelect={() => {}} });
settings={settings}
initialErrorMessage="GEMINI_API_KEY environment variable not found"
/>,
);
expect(lastFrame()).toContain( expect(lastFrame()).toContain(
'GEMINI_API_KEY environment variable not found', 'GEMINI_API_KEY environment variable not found',
@@ -116,9 +159,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now, // Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages // it won't show GEMINI_API_KEY messages
@@ -162,9 +203,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
expect(lastFrame()).not.toContain( expect(lastFrame()).not.toContain(
'Existing API key detected (GEMINI_API_KEY)', 'Existing API key detected (GEMINI_API_KEY)',
@@ -208,9 +247,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now, // Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages // it won't show GEMINI_API_KEY messages
@@ -255,9 +292,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// This is a bit brittle, but it's the best way to check which item is selected. // This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI'); expect(lastFrame()).toContain('● 2. OpenAI');
@@ -297,9 +332,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is Qwen OAuth (first option) // Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth'); expect(lastFrame()).toContain('● 1. Qwen OAuth');
@@ -341,9 +374,7 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame } = renderWithProviders( const { lastFrame } = renderAuthDialog(settings);
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore, // Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option // 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 () => { 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( const settings: LoadedSettings = new LoadedSettings(
{ {
settings: { ui: { customThemes: {} }, mcpServers: {} }, settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -386,8 +417,10 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame, stdin, unmount } = renderWithProviders( const { lastFrame, stdin, unmount } = renderAuthDialog(
<AuthDialog onSelect={onSelect} settings={settings} />, settings,
{},
{ handleAuthSelect },
); );
await wait(); await wait();
@@ -395,16 +428,16 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key stdin.write('\u001b'); // ESC key
await wait(); await wait();
// Should show error message instead of calling onSelect // Should show error message instead of calling handleAuthSelect
expect(lastFrame()).toContain( expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C again to exit.', 'You must select an auth method to proceed. Press Ctrl+C again to exit.',
); );
expect(onSelect).not.toHaveBeenCalled(); expect(handleAuthSelect).not.toHaveBeenCalled();
unmount(); unmount();
}); });
it('should not exit if there is already an error message', async () => { 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( const settings: LoadedSettings = new LoadedSettings(
{ {
settings: { ui: { customThemes: {} }, mcpServers: {} }, settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -438,12 +471,10 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { lastFrame, stdin, unmount } = renderWithProviders( const { lastFrame, stdin, unmount } = renderAuthDialog(
<AuthDialog settings,
onSelect={onSelect} { authError: 'Initial error' },
settings={settings} { handleAuthSelect },
initialErrorMessage="Initial error"
/>,
); );
await wait(); await wait();
@@ -453,13 +484,13 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key stdin.write('\u001b'); // ESC key
await wait(); await wait();
// Should not call onSelect // Should not call handleAuthSelect
expect(onSelect).not.toHaveBeenCalled(); expect(handleAuthSelect).not.toHaveBeenCalled();
unmount(); unmount();
}); });
it('should allow exiting when auth method is already selected', async () => { it('should allow exiting when auth method is already selected', async () => {
const onSelect = vi.fn(); const handleAuthSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings( const settings: LoadedSettings = new LoadedSettings(
{ {
settings: { ui: { customThemes: {} }, mcpServers: {} }, settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -493,8 +524,10 @@ describe('AuthDialog', () => {
new Set(), new Set(),
); );
const { stdin, unmount } = renderWithProviders( const { stdin, unmount } = renderAuthDialog(
<AuthDialog onSelect={onSelect} settings={settings} />, settings,
{},
{ handleAuthSelect },
); );
await wait(); await wait();
@@ -502,8 +535,8 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key stdin.write('\u001b'); // ESC key
await wait(); await wait();
// Should call onSelect with undefined to exit // Should call handleAuthSelect with undefined to exit
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount(); unmount();
}); });
}); });

View File

@@ -8,26 +8,13 @@ import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { validateAuthMethod } from '../../config/auth.js'; import { SettingScope } from '../../config/settings.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useUIState } from '../contexts/UIStateContext.js';
interface AuthDialogProps { import { useUIActions } from '../contexts/UIActionsContext.js';
onSelect: ( import { useSettings } from '../contexts/SettingsContext.js';
authMethod: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
function parseDefaultAuthType( function parseDefaultAuthType(
defaultAuthType: string | undefined, defaultAuthType: string | undefined,
@@ -41,15 +28,14 @@ function parseDefaultAuthType(
return null; return null;
} }
export function AuthDialog({ export function AuthDialog(): React.JSX.Element {
onSelect, const { pendingAuthType, authError } = useUIState();
settings, const { handleAuthSelect: onAuthSelect } = useUIActions();
initialErrorMessage, const settings = useSettings();
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>( const [errorMessage, setErrorMessage] = useState<string | null>(null);
initialErrorMessage || null, const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [ const items = [
{ {
key: AuthType.QWEN_OAUTH, key: AuthType.QWEN_OAUTH,
@@ -62,10 +48,17 @@ export function AuthDialog({
const initialAuthIndex = Math.max( const initialAuthIndex = Math.max(
0, 0,
items.findIndex((item) => { 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) { if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security?.auth?.selectedType; return item.value === settings.merged.security?.auth?.selectedType;
} }
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
const defaultAuthType = parseDefaultAuthType( const defaultAuthType = parseDefaultAuthType(
process.env['QWEN_DEFAULT_AUTH_TYPE'], process.env['QWEN_DEFAULT_AUTH_TYPE'],
); );
@@ -73,49 +66,29 @@ export function AuthDialog({
return item.value === defaultAuthType; return item.value === defaultAuthType;
} }
// Priority 4: default to QWEN_OAUTH
return item.value === AuthType.QWEN_OAUTH; return item.value === AuthType.QWEN_OAUTH;
}), }),
); );
const handleAuthSelect = (authMethod: AuthType) => { const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
if (authMethod === AuthType.USE_OPENAI) { const currentSelectedAuthType =
setShowOpenAIKeyPrompt(true); selectedIndex !== null
? items[selectedIndex]?.value
: items[initialAuthIndex]?.value;
const handleAuthSelect = async (authMethod: AuthType) => {
setErrorMessage(null); setErrorMessage(null);
} else { await onAuthSelect(authMethod, SettingScope.User);
const error = validateAuthMethod(authMethod);
if (error) {
setErrorMessage(error);
} else {
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
}
}; };
const handleOpenAIKeySubmit = ( const handleHighlight = (authMethod: AuthType) => {
apiKey: string, const index = items.findIndex((item) => item.value === authMethod);
baseUrl: string, setSelectedIndex(index);
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.');
}; };
useKeypress( useKeypress(
(key) => { (key) => {
if (showOpenAIKeyPrompt) {
return;
}
if (key.name === 'escape') { if (key.name === 'escape') {
// Prevent exit if there is an error message. // Prevent exit if there is an error message.
// This means they user is not authenticated yet. // This means they user is not authenticated yet.
@@ -129,33 +102,11 @@ export function AuthDialog({
); );
return; return;
} }
onSelect(undefined, SettingScope.User); onAuthSelect(undefined, SettingScope.User);
} }
}, },
{ isActive: true }, { 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 ( return (
<Box <Box
@@ -174,16 +125,26 @@ export function AuthDialog({
items={items} items={items}
initialIndex={initialAuthIndex} initialIndex={initialAuthIndex}
onSelect={handleAuthSelect} onSelect={handleAuthSelect}
onHighlight={handleHighlight}
/> />
</Box> </Box>
{errorMessage && ( {(authError || errorMessage) && (
<Box marginTop={1}> <Box marginTop={1}>
<Text color={Colors.AccentRed}>{errorMessage}</Text> <Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
</Box> </Box>
)} )}
<Box marginTop={1}> <Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text> <Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
</Box> </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}> <Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text> <Text>Terms of Services and Privacy Notice for Qwen Code</Text>
</Box> </Box>

View File

@@ -6,27 +6,19 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js'; 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 { import {
AuthType,
clearCachedCredentialFile, clearCachedCredentialFile,
getErrorMessage, getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { AuthState } from '../types.js'; 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( export type { QwenAuthState } from '../hooks/useQwenAuth.js';
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 const useAuthCommand = (settings: LoadedSettings, config: Config) => { export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const unAuthenticated = const unAuthenticated =
@@ -40,6 +32,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated); const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
pendingAuthType,
isAuthenticating,
);
const onAuthError = useCallback( const onAuthError = useCallback(
(error: string | null) => { (error: string | null) => {
@@ -52,90 +52,123 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
[setAuthError, setAuthState], [setAuthError, setAuthState],
); );
// Authentication flow const handleAuthFailure = useCallback(
useEffect(() => { (error: unknown) => {
const authFlow = async () => {
const authType = settings.merged.security?.auth?.selectedType;
if (isAuthDialogOpen || !authType) {
return;
}
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); 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);
} }
};
void authFlow();
}, [isAuthDialogOpen, settings, config, onAuthError]);
// Handle auth selection from dialog
const handleAuthSelect = useCallback(
async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
}, },
[onAuthError, pendingAuthType, config],
);
const handleAuthSuccess = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => { ) => {
if (authType) { try {
await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType);
// Save OpenAI credentials if provided // Only update credentials if not switching to QWEN_OAUTH,
if (credentials) { // so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
// Update Config's internal generationConfig before calling refreshAuth if (authType !== AuthType.QWEN_OAUTH && credentials) {
// This ensures refreshAuth has access to the new credentials if (credentials?.apiKey != null) {
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) {
settings.setValue( settings.setValue(
scope, scope,
'security.auth.apiKey', 'security.auth.apiKey',
credentials.apiKey, credentials.apiKey,
); );
} }
if (credentials.baseUrl) { if (credentials?.baseUrl != null) {
settings.setValue( settings.setValue(
scope, scope,
'security.auth.baseUrl', 'security.auth.baseUrl',
credentials.baseUrl, credentials.baseUrl,
); );
} }
if (credentials.model) { if (credentials?.model != null) {
settings.setValue(scope, 'model.name', credentials.model); settings.setValue(scope, 'model.name', credentials.model);
} }
await clearCachedCredentialFile();
}
} catch (error) {
handleAuthFailure(error);
return;
} }
settings.setValue(scope, 'security.auth.selectedType', authType); 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, 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); setIsAuthDialogOpen(false);
setAuthError(null); 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);
}, },
[settings, config], [config, performAuth],
); );
const openAuthDialog = useCallback(() => { const openAuthDialog = useCallback(() => {
@@ -143,8 +176,45 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
}, []); }, []);
const cancelAuthentication = useCallback(() => { 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); 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 { return {
authState, authState,
@@ -153,6 +223,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
onAuthError, onAuthError,
isAuthDialogOpen, isAuthDialogOpen,
isAuthenticating, isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect, handleAuthSelect,
openAuthDialog, openAuthDialog,
cancelAuthentication, cancelAuthentication,

View File

@@ -12,9 +12,9 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js'; import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js'; import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js'; import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js'; import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { ProQuotaDialog } from './ProQuotaDialog.js'; import { ProQuotaDialog } from './ProQuotaDialog.js';
@@ -26,6 +26,9 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.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 process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
@@ -56,6 +59,16 @@ export const DialogManager = ({
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } = const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState; 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) { if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return ( return (
<WelcomeBackDialog <WelcomeBackDialog
@@ -207,39 +220,56 @@ export const DialogManager = ({
if (uiState.isVisionSwitchDialogOpen) { if (uiState.isVisionSwitchDialogOpen) {
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />; return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
} }
if (uiState.isAuthenticating) {
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active if (uiState.isAuthDialogOpen || uiState.authError) {
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) {
return ( return (
<QwenOAuthProgress <Box flexDirection="column">
deviceAuth={uiState.deviceAuth || undefined} <AuthDialog />
authStatus={uiState.authStatus} </Box>
authMessage={uiState.authMessage} );
onTimeout={uiActions.handleQwenAuthTimeout} }
onCancel={uiActions.handleQwenAuthCancel}
if (uiState.isAuthenticating) {
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
const defaults = getDefaultOpenAIConfig();
return (
<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 if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
return ( return (
<AuthInProgress <QwenOAuthProgress
deviceAuth={uiState.qwenAuthState.deviceAuth || undefined}
authStatus={uiState.qwenAuthState.authStatus}
authMessage={uiState.qwenAuthState.authMessage}
onTimeout={() => { onTimeout={() => {
uiActions.onAuthError('Authentication cancelled.'); uiActions.onAuthError('Qwen OAuth authentication timed out.');
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}} }}
/> />
); );
} }
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
<AuthDialog
onSelect={uiActions.handleAuthSelect}
settings={settings}
initialErrorMessage={uiState.authError}
/>
</Box>
);
} }
if (uiState.isEditorDialogOpen) { if (uiState.isEditorDialogOpen) {
return ( return (

View File

@@ -6,6 +6,7 @@
import type React from 'react'; import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
@@ -18,6 +19,16 @@ interface OpenAIKeyPromptProps {
defaultModel?: string; 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({ export function OpenAIKeyPrompt({
onSubmit, onSubmit,
onCancel, onCancel,
@@ -31,6 +42,34 @@ export function OpenAIKeyPrompt({
const [currentField, setCurrentField] = useState< const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model' 'apiKey' | 'baseUrl' | 'model'
>('apiKey'); >('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( useKeypress(
(key) => { (key) => {
@@ -52,7 +91,7 @@ export function OpenAIKeyPrompt({
} else if (currentField === 'model') { } else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空 // 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) { if (apiKey.trim()) {
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim()); validateAndSubmit();
} else { } else {
// 如果 API key 为空,回到 API key 字段 // 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey'); setCurrentField('apiKey');
@@ -168,6 +207,11 @@ export function OpenAIKeyPrompt({
<Text bold color={Colors.AccentBlue}> <Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required OpenAI Configuration Required
</Text> </Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}> <Box marginTop={1}>
<Text> <Text>
Please enter your OpenAI configuration. You can get an API key from{' '} 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 { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js'; 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 { useKeypress } from '../hooks/useKeypress.js';
import type { Key } from '../contexts/KeypressContext.js'; import type { Key } from '../contexts/KeypressContext.js';
@@ -42,12 +42,13 @@ describe('QwenOAuthProgress', () => {
let keypressHandler: ((key: Key) => void) | null = null; let keypressHandler: ((key: Key) => void) | null = null;
const createMockDeviceAuth = ( const createMockDeviceAuth = (
overrides: Partial<DeviceAuthorizationInfo> = {}, overrides: Partial<DeviceAuthorizationData> = {},
): DeviceAuthorizationInfo => ({ ): DeviceAuthorizationData => ({
verification_uri: 'https://example.com/device', verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?user_code=ABC123', verification_uri_complete: 'https://example.com/device?user_code=ABC123',
user_code: 'ABC123', user_code: 'ABC123',
expires_in: 300, expires_in: 300,
device_code: 'test-device-code',
...overrides, ...overrides,
}); });
@@ -55,7 +56,7 @@ describe('QwenOAuthProgress', () => {
const renderComponent = ( const renderComponent = (
props: Partial<{ props: Partial<{
deviceAuth: DeviceAuthorizationInfo; deviceAuth: DeviceAuthorizationData;
authStatus: authStatus:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -158,7 +159,7 @@ describe('QwenOAuthProgress', () => {
}); });
it('should format time correctly', () => { it('should format time correctly', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { const deviceAuthWithCustomTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 125, // 2 minutes and 5 seconds expires_in: 125, // 2 minutes and 5 seconds
}; };
@@ -176,7 +177,7 @@ describe('QwenOAuthProgress', () => {
}); });
it('should format single digit seconds with leading zero', () => { it('should format single digit seconds with leading zero', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { const deviceAuthWithCustomTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 67, // 1 minute and 7 seconds expires_in: 67, // 1 minute and 7 seconds
}; };
@@ -196,7 +197,7 @@ describe('QwenOAuthProgress', () => {
describe('Timer functionality', () => { describe('Timer functionality', () => {
it('should countdown and call onTimeout when timer expires', async () => { it('should countdown and call onTimeout when timer expires', async () => {
const deviceAuthWithShortTime: DeviceAuthorizationInfo = { const deviceAuthWithShortTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 2, // 2 seconds expires_in: 2, // 2 seconds
}; };
@@ -520,7 +521,7 @@ describe('QwenOAuthProgress', () => {
describe('Props changes', () => { describe('Props changes', () => {
it('should display initial timer value from deviceAuth', () => { it('should display initial timer value from deviceAuth', () => {
const deviceAuthWith10Min: DeviceAuthorizationInfo = { const deviceAuthWith10Min: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 600, // 10 minutes expires_in: 600, // 10 minutes
}; };

View File

@@ -11,13 +11,13 @@ import Spinner from 'ink-spinner';
import Link from 'ink-link'; import Link from 'ink-link';
import qrcode from 'qrcode-terminal'; import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js'; import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
interface QwenOAuthProgressProps { interface QwenOAuthProgressProps {
onTimeout: () => void; onTimeout: () => void;
onCancel: () => void; onCancel: () => void;
deviceAuth?: DeviceAuthorizationInfo; deviceAuth?: DeviceAuthorizationData;
authStatus?: authStatus?:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -131,8 +131,8 @@ export function QwenOAuthProgress({
useKeypress( useKeypress(
(key) => { (key) => {
if (authStatus === 'timeout') { if (authStatus === 'timeout' || authStatus === 'error') {
// Any key press in timeout state should trigger cancel to return to auth dialog // Any key press in timeout or error state should trigger cancel to return to auth dialog
onCancel(); onCancel();
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel(); 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 // Show loading state when no device auth is available yet
if (!deviceAuth) { if (!deviceAuth) {
return ( return (

View File

@@ -487,8 +487,11 @@ describe('SettingsDialog', () => {
it('loops back when reaching the end of an enum', async () => { it('loops back when reaching the end of an enum', async () => {
vi.mocked(saveModifiedSettings).mockClear(); vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA); vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings(); const settings = createMockSettings({
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ); ui: {
theme: StringEnum.BAZ,
},
});
const onSelect = vi.fn(); const onSelect = vi.fn();
const component = ( const component = (
<KeypressProvider kittyProtocolEnabled={false}> <KeypressProvider kittyProtocolEnabled={false}>

View File

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

View File

@@ -16,6 +16,7 @@ import {
import { type SettingScope } from '../../config/settings.js'; import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js'; import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export interface UIActions { export interface UIActions {
handleThemeSelect: ( handleThemeSelect: (
@@ -30,12 +31,11 @@ export interface UIActions {
handleAuthSelect: ( handleAuthSelect: (
authType: AuthType | undefined, authType: AuthType | undefined,
scope: SettingScope, scope: SettingScope,
) => void; credentials?: OpenAICredentials,
) => Promise<void>;
setAuthState: (state: AuthState) => void; setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void; onAuthError: (error: string) => void;
// Qwen OAuth handlers cancelAuthentication: () => void;
handleQwenAuthTimeout: () => void;
handleQwenAuthCancel: () => void;
handleEditorSelect: ( handleEditorSelect: (
editorType: EditorType | undefined, editorType: EditorType | undefined,
scope: SettingScope, scope: SettingScope,

View File

@@ -16,10 +16,11 @@ import type {
HistoryItemWithoutId, HistoryItemWithoutId,
StreamingState, StreamingState,
} from '../types.js'; } 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 { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { TextBuffer } from '../components/shared/text-buffer.js';
import type { import type {
AuthType,
IdeContext, IdeContext,
ApprovalMode, ApprovalMode,
UserTierId, UserTierId,
@@ -49,18 +50,9 @@ export interface UIState {
isConfigInitialized: boolean; isConfigInitialized: boolean;
authError: string | null; authError: string | null;
isAuthDialogOpen: boolean; isAuthDialogOpen: boolean;
pendingAuthType: AuthType | undefined;
// Qwen OAuth state // Qwen OAuth state
isQwenAuth: boolean; qwenAuthState: QwenAuthState;
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage: string | null;
editorError: string | null; editorError: string | null;
isEditorDialogOpen: boolean; isEditorDialogOpen: boolean;
corgiMode: boolean; corgiMode: boolean;

View File

@@ -7,6 +7,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export interface DialogCloseOptions { export interface DialogCloseOptions {
// Theme dialog // Theme dialog
@@ -25,8 +26,9 @@ export interface DialogCloseOptions {
handleAuthSelect: ( handleAuthSelect: (
authType: AuthType | undefined, authType: AuthType | undefined,
scope: SettingScope, scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>; ) => Promise<void>;
selectedAuthType: AuthType | undefined; pendingAuthType: AuthType | undefined;
// Editor dialog // Editor dialog
isEditorDialogOpen: boolean; 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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react'; 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 { useQwenAuth } from './useQwenAuth.js';
import { import {
AuthType, AuthType,
qwenOAuth2Events, qwenOAuth2Events,
QwenOAuth2Event, QwenOAuth2Event,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
// Mock the qwenOAuth2Events // Mock the qwenOAuth2Events
vi.mock('@qwen-code/qwen-code-core', async () => { 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); const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
describe('useQwenAuth', () => { describe('useQwenAuth', () => {
const mockDeviceAuth: DeviceAuthorizationInfo = { const mockDeviceAuth: DeviceAuthorizationData = {
verification_uri: 'https://oauth.qwen.com/device', verification_uri: 'https://oauth.qwen.com/device',
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123', verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
user_code: 'ABC123', user_code: 'ABC123',
expires_in: 1800, expires_in: 1800,
device_code: 'device_code_123',
}; };
const createMockSettings = (authType: AuthType): LoadedSettings =>
({
merged: {
security: {
auth: {
selectedType: authType,
},
},
},
}) as LoadedSettings;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -63,36 +52,33 @@ describe('useQwenAuth', () => {
}); });
it('should initialize with default state when not Qwen auth', () => { it('should initialize with default state when not Qwen auth', () => {
const settings = createMockSettings(AuthType.USE_GEMINI); const { result } = renderHook(() =>
const { result } = renderHook(() => useQwenAuth(settings, false)); useQwenAuth(AuthType.USE_GEMINI, false),
);
expect(result.current).toEqual({ expect(result.current.qwenAuthState).toEqual({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, 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', () => { it('should initialize with default state when Qwen auth but not authenticating', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); const { result } = renderHook(() =>
const { result } = renderHook(() => useQwenAuth(settings, false)); useQwenAuth(AuthType.QWEN_OAUTH, false),
);
expect(result.current).toEqual({ expect(result.current.qwenAuthState).toEqual({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, 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', () => { it('should set up event listeners when Qwen auth and authenticating', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
renderHook(() => useQwenAuth(settings, true));
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith( expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri, QwenOAuth2Event.AuthUri,
@@ -105,8 +91,7 @@ describe('useQwenAuth', () => {
}); });
it('should handle device auth event', () => { it('should handle device auth event', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -115,19 +100,17 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.isQwenAuthenticating).toBe(true);
}); });
it('should handle auth progress event - success', () => { it('should handle auth progress event - success', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -140,18 +123,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('success', 'Authentication successful!'); handleAuthProgress!('success', 'Authentication successful!');
}); });
expect(result.current.authStatus).toBe('success'); expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.authMessage).toBe('Authentication successful!'); expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication successful!',
);
}); });
it('should handle auth progress event - error', () => { it('should handle auth progress event - error', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -164,18 +148,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('error', 'Authentication failed'); handleAuthProgress!('error', 'Authentication failed');
}); });
expect(result.current.authStatus).toBe('error'); expect(result.current.qwenAuthState.authStatus).toBe('error');
expect(result.current.authMessage).toBe('Authentication failed'); expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication failed',
);
}); });
it('should handle auth progress event - polling', () => { it('should handle auth progress event - polling', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -188,20 +173,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('polling', 'Waiting for user authorization...'); handleAuthProgress!('polling', 'Waiting for user authorization...');
}); });
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.authMessage).toBe( expect(result.current.qwenAuthState.authMessage).toBe(
'Waiting for user authorization...', 'Waiting for user authorization...',
); );
}); });
it('should handle auth progress event - rate_limit', () => { it('should handle auth progress event - rate_limit', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -214,7 +198,7 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!( handleAuthProgress!(
@@ -223,14 +207,13 @@ describe('useQwenAuth', () => {
); );
}); });
expect(result.current.authStatus).toBe('rate_limit'); expect(result.current.qwenAuthState.authStatus).toBe('rate_limit');
expect(result.current.authMessage).toBe( 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.', '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', () => { it('should handle auth progress event without message', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: ( let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit', status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string, message?: string,
@@ -243,27 +226,30 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
act(() => { act(() => {
handleAuthProgress!('success'); handleAuthProgress!('success');
}); });
expect(result.current.authStatus).toBe('success'); expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.authMessage).toBe(null); expect(result.current.qwenAuthState.authMessage).toBe(null);
}); });
it('should clean up event listeners when auth type changes', () => { it('should clean up event listeners when auth type changes', () => {
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook( const { rerender } = renderHook(
({ settings, isAuthenticating }) => ({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating), useQwenAuth(pendingAuthType, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } }, {
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
); );
// Change to non-Qwen auth // Change to non-Qwen auth
const geminiSettings = createMockSettings(AuthType.USE_GEMINI); rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith( expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri, QwenOAuth2Event.AuthUri,
@@ -276,9 +262,9 @@ describe('useQwenAuth', () => {
}); });
it('should clean up event listeners when authentication stops', () => { it('should clean up event listeners when authentication stops', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook( const { rerender } = renderHook(
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating), ({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
{ initialProps: { isAuthenticating: true } }, { initialProps: { isAuthenticating: true } },
); );
@@ -296,8 +282,9 @@ describe('useQwenAuth', () => {
}); });
it('should clean up event listeners on unmount', () => { it('should clean up event listeners on unmount', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); const { unmount } = renderHook(() =>
const { unmount } = renderHook(() => useQwenAuth(settings, true)); useQwenAuth(AuthType.QWEN_OAUTH, true),
);
unmount(); unmount();
@@ -312,8 +299,7 @@ describe('useQwenAuth', () => {
}); });
it('should reset state when switching from Qwen auth to another auth type', () => { it('should reset state when switching from Qwen auth to another auth type', () => {
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -323,9 +309,14 @@ describe('useQwenAuth', () => {
}); });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ settings, isAuthenticating }) => ({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating), useQwenAuth(pendingAuthType, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } }, {
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
); );
// Simulate device auth // Simulate device auth
@@ -333,22 +324,19 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
// Switch to different auth type // Switch to different auth type
const geminiSettings = createMockSettings(AuthType.USE_GEMINI); rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(result.current.isQwenAuthenticating).toBe(false); expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.deviceAuth).toBe(null); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authMessage).toBe(null);
}); });
it('should reset state when authentication stops', () => { it('should reset state when authentication stops', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -358,7 +346,8 @@ describe('useQwenAuth', () => {
}); });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating), ({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
{ initialProps: { isAuthenticating: true } }, { initialProps: { isAuthenticating: true } },
); );
@@ -367,21 +356,19 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling'); expect(result.current.qwenAuthState.authStatus).toBe('polling');
// Stop authentication // Stop authentication
rerender({ isAuthenticating: false }); rerender({ isAuthenticating: false });
expect(result.current.isQwenAuthenticating).toBe(false); expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.deviceAuth).toBe(null); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authMessage).toBe(null);
}); });
it('should handle cancelQwenAuth function', () => { it('should handle cancelQwenAuth function', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => { mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) { if (event === QwenOAuth2Event.AuthUri) {
@@ -390,53 +377,49 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events; return mockQwenOAuth2Events;
}); });
const { result } = renderHook(() => useQwenAuth(settings, true)); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
// Set up some state // Set up some state
act(() => { act(() => {
handleDeviceAuth!(mockDeviceAuth); handleDeviceAuth!(mockDeviceAuth);
}); });
expect(result.current.deviceAuth).toEqual(mockDeviceAuth); expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
// Cancel auth // Cancel auth
act(() => { act(() => {
result.current.cancelQwenAuth(); result.current.cancelQwenAuth();
}); });
expect(result.current.isQwenAuthenticating).toBe(false); expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.deviceAuth).toBe(null); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authMessage).toBe(null);
}); });
it('should maintain isQwenAuth flag correctly', () => { it('should handle different auth types correctly', () => {
// Test with Qwen OAuth // Test with Qwen OAuth - should set up event listeners when authenticating
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { result: qwenResult } = renderHook(() => 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 // Test with other auth types - should not set up event listeners
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
const { result: geminiResult } = renderHook(() => 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(() => 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', () => { it('should initialize with idle status when starting authentication with Qwen auth', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH); const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
expect(result.current.isQwenAuthenticating).toBe(true); expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.authStatus).toBe('idle'); expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
}); });
}); });

View File

@@ -5,23 +5,15 @@
*/ */
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import { import {
AuthType, AuthType,
qwenOAuth2Events, qwenOAuth2Events,
QwenOAuth2Event, QwenOAuth2Event,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
export interface DeviceAuthorizationInfo { export interface QwenAuthState {
verification_uri: string; deviceAuth: DeviceAuthorizationData | null;
verification_uri_complete: string;
user_code: string;
expires_in: number;
}
interface QwenAuthState {
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus: authStatus:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -33,25 +25,22 @@ interface QwenAuthState {
} }
export const useQwenAuth = ( export const useQwenAuth = (
settings: LoadedSettings, pendingAuthType: AuthType | undefined,
isAuthenticating: boolean, isAuthenticating: boolean,
) => { ) => {
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({ const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, authMessage: null,
}); });
const isQwenAuth = const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH;
settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH;
// Set up event listeners when authentication starts // Set up event listeners when authentication starts
useEffect(() => { useEffect(() => {
if (!isQwenAuth || !isAuthenticating) { if (!isQwenAuth || !isAuthenticating) {
// Reset state when not authenticating or not Qwen auth // Reset state when not authenticating or not Qwen auth
setQwenAuthState({ setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, authMessage: null,
@@ -61,12 +50,11 @@ export const useQwenAuth = (
setQwenAuthState((prev) => ({ setQwenAuthState((prev) => ({
...prev, ...prev,
isQwenAuthenticating: true,
authStatus: 'idle', authStatus: 'idle',
})); }));
// Set up event listeners // Set up event listeners
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => { const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => {
setQwenAuthState((prev) => ({ setQwenAuthState((prev) => ({
...prev, ...prev,
deviceAuth: { deviceAuth: {
@@ -74,6 +62,7 @@ export const useQwenAuth = (
verification_uri_complete: deviceAuth.verification_uri_complete, verification_uri_complete: deviceAuth.verification_uri_complete,
user_code: deviceAuth.user_code, user_code: deviceAuth.user_code,
expires_in: deviceAuth.expires_in, expires_in: deviceAuth.expires_in,
device_code: deviceAuth.device_code,
}, },
authStatus: 'polling', authStatus: 'polling',
})); }));
@@ -106,7 +95,6 @@ export const useQwenAuth = (
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel); qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
setQwenAuthState({ setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null, deviceAuth: null,
authStatus: 'idle', authStatus: 'idle',
authMessage: null, authMessage: null,
@@ -114,8 +102,7 @@ export const useQwenAuth = (
}, []); }, []);
return { return {
...qwenAuthState, qwenAuthState,
isQwenAuth,
cancelQwenAuth, cancelQwenAuth,
}; };
}; };

View File

@@ -30,6 +30,7 @@ export {
logExtensionEnable, logExtensionEnable,
logIdeConnection, logIdeConnection,
logExtensionDisable, logExtensionDisable,
logAuth,
} from './src/telemetry/loggers.js'; } from './src/telemetry/loggers.js';
export { export {
@@ -40,6 +41,7 @@ export {
ExtensionEnableEvent, ExtensionEnableEvent,
ExtensionUninstallEvent, ExtensionUninstallEvent,
ModelSlashCommandEvent, ModelSlashCommandEvent,
AuthEvent,
} from './src/telemetry/types.js'; } from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js'; export { makeFakeConfig } from './src/test-utils/config.js';
export * from './src/utils/pathReader.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 // Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them // throughtSignature from Genai to Vertex will fail, we need to strip them
if ( if (
@@ -582,6 +582,7 @@ export class Config {
newContentGeneratorConfig, newContentGeneratorConfig,
this, this,
this.getSessionId(), this.getSessionId(),
isInitialAuth,
); );
// Only assign to instance properties after successful initialization // Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig; this.contentGeneratorConfig = newContentGeneratorConfig;

View File

@@ -120,6 +120,7 @@ export async function createContentGenerator(
config: ContentGeneratorConfig, config: ContentGeneratorConfig,
gcConfig: Config, gcConfig: Config,
sessionId?: string, sessionId?: string,
isInitialAuth?: boolean,
): Promise<ContentGenerator> { ): Promise<ContentGenerator> {
const version = process.env['CLI_VERSION'] || process.version; const version = process.env['CLI_VERSION'] || process.version;
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
@@ -191,13 +192,17 @@ export async function createContentGenerator(
try { try {
// Get the Qwen OAuth client (now includes integrated token management) // 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 // Create the content generator with dynamic token management
return new QwenContentGenerator(qwenClient, config, gcConfig); return new QwenContentGenerator(qwenClient, config, gcConfig);
} catch (error) { } catch (error) {
throw new 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) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -983,7 +983,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1032,7 +1032,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication timed out'); ).rejects.toThrow('Authorization timeout, please restart the process.');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1082,7 +1082,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow( ).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; SharedTokenManager.getInstance = originalGetInstance;
@@ -1119,7 +1119,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1177,7 +1177,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1264,7 +1264,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow(
'Device code expired or invalid, please restart the authorization process.',
);
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });

View File

@@ -467,6 +467,7 @@ export type AuthResult =
| { | {
success: false; success: false;
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit'; 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( export async function getQwenOAuthClient(
config: Config, config: Config,
options?: { requireCachedCredentials?: boolean },
): Promise<QwenOAuth2Client> { ): Promise<QwenOAuth2Client> {
const client = new QwenOAuth2Client(); const client = new QwenOAuth2Client();
@@ -488,11 +490,6 @@ export async function getQwenOAuthClient(
client.setCredentials(credentials); client.setCredentials(credentials);
return client; return client;
} catch (error: unknown) { } catch (error: unknown) {
console.debug(
'Shared token manager failed, attempting device flow:',
error,
);
// Handle specific token manager errors // Handle specific token manager errors
if (error instanceof TokenManagerError) { if (error instanceof TokenManagerError) {
switch (error.type) { switch (error.type) {
@@ -520,12 +517,20 @@ export async function getQwenOAuthClient(
// Try device flow instead of forcing refresh // Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config); const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) { 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; 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); const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) { if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout // 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 // Use detailed error message if available, otherwise use default based on reason
const errorMessage =
result.message ||
(() => {
switch (result.reason) { switch (result.reason) {
case 'timeout': case 'timeout':
throw new Error('Qwen OAuth authentication timed out'); return 'Qwen OAuth authentication timed out';
case 'cancelled': case 'cancelled':
throw new Error('Qwen OAuth authentication was cancelled by user'); return 'Qwen OAuth authentication was cancelled by user';
case 'rate_limit': case 'rate_limit':
throw new Error( return 'Too many request for Qwen OAuth authentication, please try again later.';
'Too many request for Qwen OAuth authentication, please try again later.',
);
case 'error': case 'error':
default: default:
throw new Error('Qwen OAuth authentication failed'); return 'Qwen OAuth authentication failed';
} }
})();
throw new Error(errorMessage);
} }
return client; return client;
@@ -644,13 +653,10 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled // Check if authentication was cancelled
if (isCancelled) { if (isCancelled) {
console.debug('\nAuthentication cancelled by user.'); const message = 'Authentication cancelled by user.';
qwenOAuth2Events.emit( console.debug('\n' + message);
QwenOAuth2Event.AuthProgress, qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
'error', return { success: false, reason: 'cancelled', message };
'Authentication cancelled by user.',
);
return { success: false, reason: 'cancelled' };
} }
try { try {
@@ -738,13 +744,14 @@ async function authWithQwenDeviceFlow(
// Check for cancellation after waiting // Check for cancellation after waiting
if (isCancelled) { if (isCancelled) {
console.debug('\nAuthentication cancelled by user.'); const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit( qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress, QwenOAuth2Event.AuthProgress,
'error', 'error',
'Authentication cancelled by user.', message,
); );
return { success: false, reason: 'cancelled' }; return { success: false, reason: 'cancelled', message };
} }
continue; continue;
@@ -758,7 +765,7 @@ async function authWithQwenDeviceFlow(
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
// Handle specific error cases // Extract error information
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
const statusCode = const statusCode =
@@ -766,42 +773,49 @@ async function authWithQwenDeviceFlow(
? (error as Error & { status?: number }).status ? (error as Error & { status?: number }).status
: null; : null;
if (errorMessage.includes('401') || statusCode === 401) { // Helper function to handle error and stop polling
const message = const handleError = (
'Device code expired or invalid, please restart the authorization process.'; reason: 'error' | 'rate_limit',
message: string,
// Emit error event eventType: 'error' | 'rate_limit' = 'error',
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); ): AuthResult => {
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
qwenOAuth2Events.emit( qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress, QwenOAuth2Event.AuthProgress,
'rate_limit', eventType,
message, 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 // Handle 401 Unauthorized - device code expired or invalid
return { success: false, reason: 'rate_limit' }; 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}`; const message = `Error polling for token: ${errorMessage}`;
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
// Check for cancellation before waiting
if (isCancelled) { 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)); await new Promise((resolve) => setTimeout(resolve, pollInterval));
@@ -818,11 +832,12 @@ async function authWithQwenDeviceFlow(
); );
console.error('\n' + timeoutMessage); console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout' }; return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Device authorization flow failed:', errorMessage); const message = `Device authorization flow failed: ${errorMessage}`;
return { success: false, reason: 'error' }; console.error(message);
return { success: false, reason: 'error', message };
} finally { } finally {
// Clean up event listener // Clean up event listener
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler); qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
@@ -852,10 +867,30 @@ async function loadCachedQwenCredentials(
async function cacheQwenCredentials(credentials: QwenCredentials) { async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath(); const filePath = getQwenCachedCredentialPath();
try {
await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.mkdir(path.dirname(filePath), { recursive: true });
const credString = JSON.stringify(credentials, null, 2); const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString); 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_FILE_OPERATION = 'qwen-code.file_operation';
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model'; export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution'; export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_AUTH = 'qwen-code.auth';
// Performance Events // Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance'; export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

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

View File

@@ -37,6 +37,7 @@ import {
EVENT_SUBAGENT_EXECUTION, EVENT_SUBAGENT_EXECUTION,
EVENT_MALFORMED_JSON_RESPONSE, EVENT_MALFORMED_JSON_RESPONSE,
EVENT_INVALID_CHUNK, EVENT_INVALID_CHUNK,
EVENT_AUTH,
} from './constants.js'; } from './constants.js';
import { import {
recordApiErrorMetrics, recordApiErrorMetrics,
@@ -83,6 +84,7 @@ import type {
SubagentExecutionEvent, SubagentExecutionEvent,
MalformedJsonResponseEvent, MalformedJsonResponseEvent,
InvalidChunkEvent, InvalidChunkEvent,
AuthEvent,
} from './types.js'; } from './types.js';
import type { UiEvent } from './uiTelemetry.js'; import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js';
@@ -838,3 +840,29 @@ export function logExtensionDisable(
}; };
logger.emit(logRecord); 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, ExtensionEnableEvent,
ModelSlashCommandEvent, ModelSlashCommandEvent,
ExtensionDisableEvent, ExtensionDisableEvent,
AuthEvent,
} from '../types.js'; } from '../types.js';
import { EndSessionEvent } from '../types.js'; import { EndSessionEvent } from '../types.js';
import type { import type {
@@ -746,6 +747,25 @@ export class QwenLogger {
this.flushIfNeeded(); 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 // misc events
logFlashFallbackEvent(event: FlashFallbackEvent): void { logFlashFallbackEvent(event: FlashFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'flash_fallback', { 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 = export type TelemetryEvent =
| StartSessionEvent | StartSessionEvent
| EndSessionEvent | EndSessionEvent
@@ -713,7 +736,8 @@ export type TelemetryEvent =
| ExtensionInstallEvent | ExtensionInstallEvent
| ExtensionUninstallEvent | ExtensionUninstallEvent
| ToolOutputTruncatedEvent | ToolOutputTruncatedEvent
| ModelSlashCommandEvent; | ModelSlashCommandEvent
| AuthEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent { export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable'; '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`