Merge branch 'main' into feat/tpm-throttling-retry-wenshao

This commit is contained in:
yiliang114 2026-02-12 17:01:18 +08:00
commit 3153ff5caa
55 changed files with 3003 additions and 654 deletions

View file

@ -94,6 +94,7 @@ import {
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
@ -232,6 +233,9 @@ export const AppContainer = (props: AppContainerProps) => {
config.getWorkingDir(),
);
const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
useCodingPlanUpdates(settings, config, historyManager.addItem);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true),
@ -402,6 +406,7 @@ export const AppContainer = (props: AppContainerProps) => {
pendingAuthType,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem, refreshStatic);
@ -1275,6 +1280,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!codingPlanUpdateRequest ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
@ -1339,6 +1345,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@ -1429,6 +1436,7 @@ export const AppContainer = (props: AppContainerProps) => {
shellConfirmationRequest,
confirmationRequest,
confirmUpdateExtensionRequests,
codingPlanUpdateRequest,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
@ -1508,10 +1516,12 @@ export const AppContainer = (props: AppContainerProps) => {
setAuthState,
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@ -1552,10 +1562,12 @@ export const AppContainer = (props: AppContainerProps) => {
setAuthState,
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,

View file

@ -169,9 +169,9 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// Since the auth dialog shows API-KEY option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
expect(lastFrame()).toContain('API-KEY');
});
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
@ -257,15 +257,17 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// Since the auth dialog shows API-KEY option now,
// it won't show GEMINI_API_KEY messages
expect(lastFrame()).toContain('OpenAI');
expect(lastFrame()).toContain('API-KEY');
});
});
describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => {
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
// QWEN_OAUTH is the only valid AuthType that can be selected via env var
// API-KEY is not an AuthType enum value, so it cannot be selected this way
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.QWEN_OAUTH;
const settings: LoadedSettings = new LoadedSettings(
{
@ -302,8 +304,8 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI');
// QWEN_OAUTH is the first option, so it should be selected
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {

View file

@ -8,14 +8,20 @@ import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { ApiKeyInput } from '../components/ApiKeyInput.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { t } from '../../i18n/index.js';
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
function parseDefaultAuthType(
defaultAuthType: string | undefined,
): AuthType | null {
@ -28,30 +34,57 @@ function parseDefaultAuthType(
return null;
}
// Sub-mode types for API-KEY authentication
type ApiKeySubMode = 'coding-plan' | 'custom';
// View level for navigation
type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info';
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions();
const {
handleAuthSelect: onAuthSelect,
handleCodingPlanSubmit,
onAuthError,
} = useUIActions();
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [viewLevel, setViewLevel] = useState<ViewLevel>('main');
const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState<number>(0);
const items = [
// Main authentication entries
const mainItems = [
{
key: AuthType.QWEN_OAUTH,
label: t('Qwen OAuth'),
value: AuthType.QWEN_OAUTH,
},
{
key: AuthType.USE_OPENAI,
label: t('OpenAI'),
value: AuthType.USE_OPENAI,
key: 'API-KEY',
label: t('API-KEY'),
value: 'API-KEY' as const,
},
];
// API-KEY sub-mode entries
const apiKeySubItems = [
{
key: 'coding-plan',
label: t('Coding Plan (Bailian)'),
value: 'coding-plan' as ApiKeySubMode,
},
{
key: 'custom',
label: t('Custom'),
value: 'custom' as ApiKeySubMode,
},
];
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
mainItems.findIndex((item) => {
// Priority 1: pendingAuthType
if (pendingAuthType) {
return item.value === pendingAuthType;
@ -79,29 +112,78 @@ export function AuthDialog(): React.JSX.Element {
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? items[selectedIndex]?.value
: items[initialAuthIndex]?.value;
? mainItems[selectedIndex]?.value
: mainItems[initialAuthIndex]?.value;
const handleAuthSelect = async (authMethod: AuthType) => {
const handleMainSelect = async (
value: (typeof mainItems)[number]['value'],
) => {
setErrorMessage(null);
await onAuthSelect(authMethod);
onAuthError(null);
if (value === 'API-KEY') {
// Navigate to API-KEY sub-mode selection
setViewLevel('api-key-sub');
return;
}
// For Qwen OAuth, proceed directly
await onAuthSelect(value);
};
const handleHighlight = (authMethod: AuthType) => {
const index = items.findIndex((item) => item.value === authMethod);
setSelectedIndex(index);
const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => {
setErrorMessage(null);
onAuthError(null);
if (subMode === 'coding-plan') {
setViewLevel('api-key-input');
} else {
setViewLevel('custom-info');
}
};
const handleApiKeyInputSubmit = async (apiKey: string) => {
setErrorMessage(null);
if (!apiKey.trim()) {
setErrorMessage(t('API key cannot be empty.'));
return;
}
// Submit to parent for processing
await handleCodingPlanSubmit(apiKey);
};
const handleGoBack = () => {
setErrorMessage(null);
onAuthError(null);
if (viewLevel === 'api-key-sub') {
setViewLevel('main');
} else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
setViewLevel('api-key-sub');
}
};
useKeypress(
(key) => {
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
// Handle Escape based on current view level
if (viewLevel === 'api-key-sub') {
handleGoBack();
return;
}
if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
handleGoBack();
return;
}
// For main view, use existing logic
if (errorMessage) {
return;
}
if (config.getAuthType() === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
t(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
@ -115,51 +197,212 @@ export function AuthDialog(): React.JSX.Element {
{ isActive: true },
);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Get started')}</Text>
// Render main auth selection
const renderMainView = () => (
<>
<Box marginTop={1}>
<Text>{t('How would you like to authenticate for this project?')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
items={mainItems}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
onHighlight={handleHighlight}
onSelect={handleMainSelect}
onHighlight={(value) => {
const index = mainItems.findIndex((item) => item.value === value);
setSelectedIndex(index);
}}
/>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={Colors.Gray}>
{currentSelectedAuthType === AuthType.QWEN_OAUTH
? t('Login with QwenChat account to use daily free quota.')
: t('Use coding plan credentials or your own api-keys/providers.')}
</Text>
</Box>
</>
);
// Render API-KEY sub-mode selection
const renderApiKeySubView = () => (
<>
<Box marginTop={1}>
<Text>{t('Select API-KEY configuration mode:')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={apiKeySubItems}
initialIndex={apiKeySubModeIndex}
onSelect={handleApiKeySubSelect}
onHighlight={(value) => {
const index = apiKeySubItems.findIndex(
(item) => item.value === value,
);
setApiKeySubModeIndex(index);
}}
/>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={Colors.Gray}>
{apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan'
? t("Paste your api key of Bailian Coding Plan and you're all set!")
: t(
'More instructions about configuring `modelProviders` manually.',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
</Text>
</Box>
</>
);
// Render API key input for coding-plan mode
const renderApiKeyInputView = () => (
<Box marginTop={1}>
<ApiKeyInput onSubmit={handleApiKeyInputSubmit} onCancel={handleGoBack} />
</Box>
);
// Render custom mode info
const renderCustomInfoView = () => (
<>
<Box marginTop={1}>
<Text bold>{t('Custom API-KEY Configuration')}</Text>
</Box>
<Box marginTop={1}>
<Text>
{t('For advanced users who want to configure models manually.')}
</Text>
</Box>
<Box marginTop={1}>
<Text>{t('Please configure your models in settings.json:')}</Text>
</Box>
<Box marginTop={1} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
2.{' '}
{t(
"Add model configuration to modelProviders['openai'] (or other auth types)",
)}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
3.{' '}
{t(
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
)}
</Text>
</Box>
<Box marginTop={0} paddingLeft={2}>
<Text color={Colors.AccentYellow}>
4.{' '}
{t(
'Use /model command to select your preferred model from the configured list',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t(
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.',
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary} underline>
{t('More instructions please check:')}
</Text>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={Colors.AccentGreen} underline>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('(Press Escape to go back)')}
</Text>
</Box>
</>
);
const getViewTitle = () => {
switch (viewLevel) {
case 'main':
return t('Get started');
case 'api-key-sub':
return t('API-KEY Configuration');
case 'api-key-input':
return t('Coding Plan Setup');
case 'custom-info':
return t('Custom Configuration');
default:
return t('Get started');
}
};
return (
<Box
borderStyle="round"
borderColor={theme?.border?.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{getViewTitle()}</Text>
{viewLevel === 'main' && renderMainView()}
{viewLevel === 'api-key-sub' && renderApiKeySubView()}
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
{viewLevel === 'custom-info' && renderCustomInfoView()}
{(authError || errorMessage) && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
{t(
'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>
{viewLevel === 'main' && (
<>
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>
{t('(Use Enter to Set Auth)')}
</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t(
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
)}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
{t('Terms of Services and Privacy Notice for Qwen Code')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'
}
</Text>
</Box>
</>
)}
<Box marginTop={1}>
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
</Text>
</Box>
</Box>
);
}

View file

@ -8,6 +8,7 @@ import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import {
AuthEvent,
@ -18,11 +19,21 @@ import {
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { t } from '../../i18n/index.js';
import {
CODING_PLAN_MODELS,
CODING_PLAN_ENV_KEY,
CODING_PLAN_VERSION,
} from '../../constants/codingPlan.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@ -272,6 +283,129 @@ export const useAuthCommand = (
setAuthError(null);
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
/**
* Handle coding plan submission - generates configs from template and stores api-key
*/
const handleCodingPlanSubmit = useCallback(
async (apiKey: string) => {
try {
setIsAuthenticating(true);
setAuthError(null);
const envKeyName = CODING_PLAN_ENV_KEY;
// Get persist scope
const persistScope = getPersistScopeForModelSelection(settings);
// Store api-key in settings.env
settings.setValue(persistScope, `env.${envKeyName}`, apiKey);
// Sync to process.env immediately so refreshAuth can read the apiKey
process.env[envKeyName] = apiKey;
// Generate model configs from template
const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map(
(templateConfig) => ({
...templateConfig,
envKey: envKeyName,
}),
);
// Get existing configs
const existingConfigs =
(
settings.merged.modelProviders as ModelProvidersConfig | undefined
)?.[AuthType.USE_OPENAI] || [];
// Identify Coding Plan configs by baseUrl + envKey
// Remove existing Coding Plan configs to ensure template changes are applied
const isCodingPlanConfig = (config: ProviderModelConfig) =>
config.envKey === envKeyName &&
CODING_PLAN_MODELS.some(
(template) => template.baseUrl === config.baseUrl,
);
// Filter out existing Coding Plan configs, keep user custom configs
const nonCodingPlanConfigs = existingConfigs.filter(
(existing) => !isCodingPlanConfig(existing),
);
// Add new Coding Plan configs at the beginning
const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
// Persist to modelProviders
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Also persist authType
settings.setValue(
persistScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
// Persist coding plan version for future update detection
settings.setValue(
persistScope,
'codingPlan.version',
CODING_PLAN_VERSION,
);
// If there are configs, use the first one as the model
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
settings.setValue(persistScope, 'model.name', updatedConfigs[0].id);
}
// Hot-reload model providers configuration before refreshAuth
// This ensures ModelsConfig has the latest configuration from settings.json
const updatedModelProviders: ModelProvidersConfig = {
...(settings.merged.modelProviders as
| ModelProvidersConfig
| undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(updatedModelProviders);
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
// Success handling
setAuthError(null);
setAuthState(AuthState.Authenticated);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
// Trigger UI refresh
onAuthChange?.();
// Add success message
addItem(
{
type: MessageType.INFO,
text: t(
'Authenticated successfully with Coding Plan. API key is stored in settings.env.',
),
},
Date.now(),
);
// Log success
const authEvent = new AuthEvent(
AuthType.USE_OPENAI,
'coding-plan',
'success',
);
logAuth(config, authEvent);
} catch (error) {
handleAuthFailure(error);
}
},
[settings, config, handleAuthFailure, addItem, onAuthChange],
);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
@ -322,6 +456,7 @@ export const useAuthCommand = (
pendingAuthType,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
openAuthDialog,
cancelAuthentication,
};

View file

@ -31,6 +31,8 @@ describe('authCommand', () => {
it('should have the correct name and description', () => {
expect(authCommand.name).toBe('auth');
expect(authCommand.description).toBe('change the auth method');
expect(authCommand.description).toBe(
'Configure authentication information for login',
);
});
});

View file

@ -10,8 +10,9 @@ import { t } from '../../i18n/index.js';
export const authCommand: SlashCommand = {
name: 'auth',
altNames: ['login'],
get description() {
return t('change the auth method');
return t('Configure authentication information for login');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({

View file

@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import { TextInput } from './shared/TextInput.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
import Link from 'ink-link';
interface ApiKeyInputProps {
onSubmit: (apiKey: string) => void;
onCancel: () => void;
}
const CODING_PLAN_API_KEY_URL =
'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan';
export function ApiKeyInput({
onSubmit,
onCancel,
}: ApiKeyInputProps): React.JSX.Element {
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
useKeypress(
(key) => {
if (key.name === 'escape') {
onCancel();
} else if (key.name === 'return') {
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
setError(t('API key cannot be empty.'));
return;
}
onSubmit(trimmedKey);
}
},
{ isActive: true },
);
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text>{t('Please enter your API key:')}</Text>
</Box>
<TextInput value={apiKey} onChange={setApiKey} placeholder="sk-sp-..." />
{error && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{error}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>{t('You can get your exclusive Coding Plan API-KEY here:')}</Text>
</Box>
<Box marginTop={0}>
<Link url={CODING_PLAN_API_KEY_URL} fallback={false}>
<Text color={Colors.AccentGreen} underline>
{CODING_PLAN_API_KEY_URL}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
{t('(Press Enter to submit, Escape to cancel)')}
</Text>
</Box>
</Box>
);
}

View file

@ -17,7 +17,6 @@ import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
@ -56,16 +55,6 @@ export const DialogManager = ({
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
const getDefaultOpenAIConfig = () => {
const fromSettings = settings.merged.security?.auth;
const modelSettings = settings.merged.model;
return {
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
};
};
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return (
<WelcomeBackDialog
@ -133,6 +122,15 @@ export const DialogManager = ({
/>
);
}
if (uiState.codingPlanUpdateRequest) {
return (
<ConsentPrompt
prompt={uiState.codingPlanUpdateRequest.prompt}
onConfirm={uiState.codingPlanUpdateRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.settingInputRequests.length > 0) {
const request = uiState.settingInputRequests[0];
// Use settingName as key to force re-mount when switching between different settings
@ -251,28 +249,8 @@ export const DialogManager = ({
}
if (uiState.isAuthenticating) {
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
const defaults = getDefaultOpenAIConfig();
return (
<OpenAIKeyPrompt
onSubmit={(apiKey, baseUrl, model) => {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
apiKey,
baseUrl,
model,
});
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
/>
);
}
// OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes
// Qwen OAuth remains as a separate flow
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
return (
<QwenOAuthProgress

View file

@ -1,74 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
// Mock useKeypress hook
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('OpenAIKeyPrompt', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the prompt correctly', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(lastFrame()).toContain('OpenAI Configuration Required');
expect(lastFrame()).toContain(
'https://bailian.console.aliyun.com/?tab=model#/api-key',
);
expect(lastFrame()).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should show the component with proper styling', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
const output = lastFrame();
expect(output).toContain('OpenAI Configuration Required');
expect(output).toContain('API Key:');
expect(output).toContain('Base URL:');
expect(output).toContain('Model:');
expect(output).toContain(
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
);
});
it('should handle paste with control characters', async () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
);
// Simulate paste with control characters
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
stdin.write(pasteWithControlChars);
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// The component should have filtered out the control characters
// and only kept 'sk-test123'
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
});
});

View file

@ -1,280 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
onCancel: () => void;
defaultApiKey?: string;
defaultBaseUrl?: 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({
onSubmit,
onCancel,
defaultApiKey,
defaultBaseUrl,
defaultModel,
}: OpenAIKeyPromptProps): React.JSX.Element {
const [apiKey, setApiKey] = useState(defaultApiKey || '');
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
const [model, setModel] = useState(defaultModel || '');
const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
const [validationError, setValidationError] = useState<string | null>(null);
const validateAndSubmit = () => {
setValidationError(null);
try {
const validated = credentialSchema.parse({
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim() || undefined,
model: model.trim() || undefined,
});
onSubmit(
validated.apiKey,
validated.baseUrl === '' ? '' : validated.baseUrl || '',
validated.model || '',
);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessage = error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
setValidationError(
t('Invalid credentials: {{errorMessage}}', { errorMessage }),
);
} else {
setValidationError(t('Failed to validate credentials'));
}
}
};
useKeypress(
(key) => {
// Handle escape
if (key.name === 'escape') {
onCancel();
return;
}
// Handle Enter key
if (key.name === 'return') {
if (currentField === 'apiKey') {
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
setCurrentField('baseUrl');
return;
} else if (currentField === 'baseUrl') {
setCurrentField('model');
return;
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
validateAndSubmit();
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
}
}
return;
}
// Handle Tab key for field navigation
if (key.name === 'tab') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
} else if (currentField === 'model') {
setCurrentField('apiKey');
}
return;
}
// Handle arrow keys for field navigation
if (key.name === 'up') {
if (currentField === 'baseUrl') {
setCurrentField('apiKey');
} else if (currentField === 'model') {
setCurrentField('baseUrl');
}
return;
}
if (key.name === 'down') {
if (currentField === 'apiKey') {
setCurrentField('baseUrl');
} else if (currentField === 'baseUrl') {
setCurrentField('model');
}
return;
}
// Handle backspace/delete
if (key.name === 'backspace' || key.name === 'delete') {
if (currentField === 'apiKey') {
setApiKey((prev) => prev.slice(0, -1));
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev.slice(0, -1));
} else if (currentField === 'model') {
setModel((prev) => prev.slice(0, -1));
}
return;
}
// Handle paste mode - if it's a paste event with content
if (key.paste && key.sequence) {
// 过滤粘贴相关的控制序列
let cleanInput = key.sequence
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
// 过滤粘贴开始标记 [200~
.replace(/\[200~/g, '')
// 过滤粘贴结束标记 [201~
.replace(/\[201~/g, '')
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
.replace(/^\[|~$/g, '');
// 再过滤所有不可见字符ASCII < 32除了回车换行
cleanInput = cleanInput
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
return;
}
// Handle regular character input
if (key.sequence && !key.ctrl && !key.meta) {
// Filter control characters
const cleanInput = key.sequence
.split('')
.filter((ch) => ch.charCodeAt(0) >= 32)
.join('');
if (cleanInput.length > 0) {
if (currentField === 'apiKey') {
setApiKey((prev) => prev + cleanInput);
} else if (currentField === 'baseUrl') {
setBaseUrl((prev) => prev + cleanInput);
} else if (currentField === 'model') {
setModel((prev) => prev + cleanInput);
}
}
}
},
{ isActive: true },
);
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
{t('OpenAI Configuration Required')}
</Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
{t(
'Please enter your OpenAI configuration. You can get an API key from',
)}{' '}
<Text color={Colors.AccentBlue}>
https://bailian.console.aliyun.com/?tab=model#/api-key
</Text>
</Text>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
>
{t('API Key:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'apiKey' ? '> ' : ' '}
{apiKey || ' '}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
>
{t('Base URL:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'baseUrl' ? '> ' : ' '}
{baseUrl}
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="row">
<Box width={12}>
<Text
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
>
{t('Model:')}
</Text>
</Box>
<Box flexGrow={1}>
<Text>
{currentField === 'model' ? '> ' : ' '}
{model}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
{t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -20,6 +20,7 @@ import type {
PlanResultDisplay,
AnsiOutput,
Config,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from '../subagents/index.js';
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
@ -113,6 +114,22 @@ const useResultDisplayRenderer = (
};
}
// Check for McpToolProgressData
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'mcp_tool_progress'
) {
const progress = resultDisplay as McpToolProgressData;
const msg = progress.message ?? `Progress: ${progress.progress}`;
const totalStr = progress.total != null ? `/${progress.total}` : '';
return {
type: 'string',
data: `⏳ [${progress.progress}${totalStr}] ${msg}`,
};
}
// Check for AnsiOutput
if (
typeof resultDisplay === 'object' &&

View file

@ -17,7 +17,12 @@ import {
import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
export interface UIActions {
openThemeDialog: () => void;
@ -35,8 +40,9 @@ export interface UIActions {
authType: AuthType | undefined,
credentials?: OpenAICredentials,
) => Promise<void>;
handleCodingPlanSubmit: (apiKey: string) => Promise<void>;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void;
onAuthError: (error: string | null) => void;
cancelAuthentication: () => void;
handleEditorSelect: (
editorType: EditorType | undefined,
@ -45,6 +51,7 @@ export interface UIActions {
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
dismissCodingPlanUpdate: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;

View file

@ -32,6 +32,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
export interface UIState {
history: HistoryItem[];
@ -60,6 +61,7 @@ export interface UIState {
shellConfirmationRequest: ShellConfirmationRequest | null;
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined;
settingInputRequests: SettingInputRequest[];
pluginChoiceRequests: PluginChoiceRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;

View file

@ -0,0 +1,288 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useCodingPlanUpdates } from './useCodingPlanUpdates.js';
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
import { AuthType } from '@qwen-code/qwen-code-core';
// Mock the constants module
vi.mock('../../constants/codingPlan.js', async () => {
const actual = await vi.importActual('../../constants/codingPlan.js');
return {
...actual,
CODING_PLAN_VERSION: 'test-version-hash',
CODING_PLAN_MODELS: [
{
id: 'test-model-1',
name: 'Test Model 1',
baseUrl: 'https://test.example.com/v1',
description: 'Test model 1',
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
},
{
id: 'test-model-2',
name: 'Test Model 2',
baseUrl: 'https://test.example.com/v1',
description: 'Test model 2',
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
},
],
};
});
describe('useCodingPlanUpdates', () => {
const mockSettings = {
merged: {
modelProviders: {},
codingPlan: {},
},
setValue: vi.fn(),
isTrusted: true,
workspace: { settings: {} },
user: { settings: {} },
};
const mockConfig = {
reloadModelProvidersConfig: vi.fn(),
refreshAuth: vi.fn(),
};
const mockAddItem = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
});
describe('version comparison', () => {
it('should not show update prompt when no version is stored', () => {
mockSettings.merged.codingPlan = {};
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
it('should not show update prompt when versions match', () => {
mockSettings.merged.codingPlan = { version: 'test-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
it('should show update prompt when versions differ', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
'New model configurations',
);
});
});
describe('update execution', () => {
it('should execute update when user confirms', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-1',
baseUrl: 'https://test.example.com/v1',
envKey: CODING_PLAN_ENV_KEY,
},
{
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
},
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Confirm the update
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
// Should update model providers (at least 2 calls: modelProviders + version)
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should update version
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'codingPlan.version',
'test-version-hash',
);
// Should reload and refresh auth
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
// Should show success message
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('updated successfully'),
}),
expect.any(Number),
);
});
it('should not execute update when user declines', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
// Decline the update
await result.current.codingPlanUpdateRequest!.onConfirm(false);
// Should not update anything
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
});
it('should preserve non-Coding Plan configs during update', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const customConfig = {
id: 'custom-model',
baseUrl: 'https://custom.example.com',
envKey: 'CUSTOM_API_KEY',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'test-model-1',
baseUrl: 'https://test.example.com/v1',
envKey: CODING_PLAN_ENV_KEY,
},
customConfig,
],
};
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Wait for async update to complete
await waitFor(() => {
// Should preserve custom config - verify setValue was called
expect(mockSettings.setValue).toHaveBeenCalled();
});
});
it('should handle missing API key error', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
// Should show error message
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
expect.any(Number),
);
});
});
describe('dismissUpdate', () => {
it('should clear update request when dismissed', async () => {
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
result.current.dismissCodingPlanUpdate();
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
});
});
});
});

View file

@ -0,0 +1,201 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useState } from 'react';
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import {
CODING_PLAN_MODELS,
CODING_PLAN_ENV_KEY,
CODING_PLAN_VERSION,
} from '../../constants/codingPlan.js';
import { t } from '../../i18n/index.js';
export interface CodingPlanUpdateRequest {
prompt: string;
onConfirm: (confirmed: boolean) => void;
}
/**
* Checks if a config is a Coding Plan configuration by matching baseUrl and envKey.
* This ensures only configs from the Coding Plan provider are identified.
*/
function isCodingPlanConfig(config: {
baseUrl?: string;
envKey?: string;
}): boolean {
return (
config.envKey === CODING_PLAN_ENV_KEY &&
CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl)
);
}
/**
* Hook for detecting and handling Coding Plan template updates.
* Compares the persisted version with the current template version
* and prompts the user to update if they differ.
*/
export function useCodingPlanUpdates(
settings: LoadedSettings,
config: Config,
addItem: (
item: { type: 'info' | 'error' | 'warning'; text: string },
timestamp: number,
) => void,
) {
const [updateRequest, setUpdateRequest] = useState<
CodingPlanUpdateRequest | undefined
>();
/**
* Execute the Coding Plan configuration update.
* Removes old Coding Plan configs and replaces them with new ones from the template.
*/
const executeUpdate = useCallback(async () => {
try {
const persistScope = getPersistScopeForModelSelection(settings);
// Get current configs
const currentConfigs =
(
settings.merged.modelProviders as
| Record<string, Array<Record<string, unknown>>>
| undefined
)?.[AuthType.USE_OPENAI] || [];
// Filter out Coding Plan configs (keep user custom configs)
const nonCodingPlanConfigs = currentConfigs.filter(
(cfg) =>
!isCodingPlanConfig({
baseUrl: cfg['baseUrl'] as string | undefined,
envKey: cfg['envKey'] as string | undefined,
}),
);
// Generate new configs from template with the stored API key
const apiKey = process.env[CODING_PLAN_ENV_KEY];
if (!apiKey) {
throw new Error(
t(
'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
),
);
}
const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({
...templateConfig,
envKey: CODING_PLAN_ENV_KEY,
}));
// Combine: new Coding Plan configs at the front, user configs preserved
const updatedConfigs = [
...newConfigs,
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
] as Array<Record<string, unknown>>;
// Persist updated model providers
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Update the version
settings.setValue(
persistScope,
'codingPlan.version',
CODING_PLAN_VERSION,
);
// Hot-reload model providers configuration
const updatedModelProviders = {
...(settings.merged.modelProviders as
| Record<string, unknown>
| undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(
updatedModelProviders as unknown as ModelProvidersConfig,
);
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
addItem(
{
type: 'info',
text: t(
'Coding Plan configuration updated successfully. New models are now available.',
),
},
Date.now(),
);
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
addItem(
{
type: 'error',
text: t('Failed to update Coding Plan configuration: {{message}}', {
message: errorMessage,
}),
},
Date.now(),
);
return false;
}
}, [settings, config, addItem]);
/**
* Check for version mismatch and prompt user for update if needed.
*/
const checkForUpdates = useCallback(() => {
const savedVersion = (
settings.merged as { codingPlan?: { version?: string } }
).codingPlan?.version;
// If no version is stored, user hasn't used Coding Plan yet - skip check
if (!savedVersion) {
return;
}
// If versions match, no update needed
if (savedVersion === CODING_PLAN_VERSION) {
return;
}
// Version mismatch - prompt user for update
setUpdateRequest({
prompt: t(
'New model configurations are available for Bailian Coding Plan. Update now?',
),
onConfirm: async (confirmed: boolean) => {
setUpdateRequest(undefined);
if (confirmed) {
await executeUpdate();
}
},
});
}, [settings, executeUpdate]);
// Check for updates on mount
useEffect(() => {
checkForUpdates();
}, [checkForUpdates]);
const dismissCodingPlanUpdate = useCallback(() => {
setUpdateRequest(undefined);
}, []);
return {
codingPlanUpdateRequest: updateRequest,
dismissCodingPlanUpdate,
};
}

View file

@ -7,7 +7,12 @@
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
interface OpenAICredentials {
apiKey: string;
baseUrl?: string;
model?: string;
}
export interface DialogCloseOptions {
// Theme dialog