diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index f131b41e8..dd3421b6c 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -17,7 +17,7 @@ import { isCodingPlanConfig, CodingPlanRegion, CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlan.js'; +} from '@qwen-code/qwen-code-core'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { backupSettingsFile } from '../../utils/settingsUtils.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index d087d8d55..ca5a52718 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -6,8 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { showAuthStatus } from './handler.js'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import { AuthType, CODING_PLAN_ENV_KEY } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../../config/settings.js', () => ({ diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index c5ef088c6..f7fb402b2 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -6,7 +6,11 @@ import type React from 'react'; import { useState } from 'react'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { + AuthType, + CodingPlanRegion, + isCodingPlanConfig, +} from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; import Link from 'ink-link'; import { theme } from '../semantic-colors.js'; @@ -18,10 +22,6 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; -import { - CodingPlanRegion, - isCodingPlanConfig, -} from '../../constants/codingPlan.js'; import { ALIBABA_STANDARD_API_KEY_ENDPOINTS, type AlibabaStandardRegion, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 86c3857aa..eb32d7f87 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -15,6 +15,10 @@ import { AuthType, getErrorMessage, logAuth, + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, + CODING_PLAN_ENV_KEY, } from '@qwen-code/qwen-code-core'; import { useCallback, useEffect, useState } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; @@ -29,12 +33,6 @@ 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 { - getCodingPlanConfig, - isCodingPlanConfig, - CodingPlanRegion, - CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlan.js'; import { backupSettingsFile } from '../../utils/settingsUtils.js'; import { ALIBABA_STANDARD_API_KEY_ENDPOINTS, diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index bf885b30d..8ccc616f1 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -11,7 +11,7 @@ import { TextInput } from './shared/TextInput.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; -import { CodingPlanRegion } from '../../constants/codingPlan.js'; +import { CodingPlanRegion } from '@qwen-code/qwen-code-core'; import Link from 'ink-link'; interface ApiKeyInputProps { diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 0254a2012..fa051e7e0 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -5,13 +5,12 @@ */ import { Box } from 'ink'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, isCodingPlanConfig } from '@qwen-code/qwen-code-core'; import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; -import { isCodingPlanConfig } from '../../constants/codingPlan.js'; interface AppHeaderProps { version: string; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f068e16d1..5de8efa54 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -13,9 +13,9 @@ import { type AuthType, type EditorType, type ApprovalMode, + type CodingPlanRegion, } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; -import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js'; import type { AuthState } from '../types.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index c61e8c990..a657fd0bb 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -11,8 +11,8 @@ import { CODING_PLAN_ENV_KEY, getCodingPlanConfig, CodingPlanRegion, -} from '../../constants/codingPlan.js'; -import { AuthType } from '@qwen-code/qwen-code-core'; + AuthType, +} from '@qwen-code/qwen-code-core'; // Get region configs for testing const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 1d341b31f..6c8e2b4c1 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -6,15 +6,15 @@ 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 { + AuthType, isCodingPlanConfig, getCodingPlanConfig, CodingPlanRegion, CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlan.js'; +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; export interface CodingPlanUpdateRequest { diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts index ec40afada..c935f0386 100644 --- a/packages/cli/src/utils/systemInfoFields.ts +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -6,7 +6,7 @@ import type { ExtendedSystemInfo } from './systemInfo.js'; import { t } from '../i18n/index.js'; -import { isCodingPlanConfig } from '../constants/codingPlan.js'; +import { isCodingPlanConfig } from '@qwen-code/qwen-code-core'; /** * Field configuration for system information display diff --git a/packages/core/src/constants/codingPlan.ts b/packages/core/src/constants/codingPlan.ts new file mode 100644 index 000000000..3593a5780 --- /dev/null +++ b/packages/core/src/constants/codingPlan.ts @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Coding Plan constants — shared between CLI and VSCode extension. + * Single source of truth for model templates, regions, and env keys. + */ + +import { createHash } from 'node:crypto'; +import type { ModelConfig } from '../models/types.js'; + +/** + * Coding plan regions + */ +export enum CodingPlanRegion { + CHINA = 'china', + GLOBAL = 'global', +} + +/** + * Coding plan template - array of model configurations + * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key + */ +export type CodingPlanTemplate = ModelConfig[]; + +/** + * Environment variable key for storing the coding plan API key. + * Unified key for both regions since they are mutually exclusive. + */ +export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; + +/** + * Computes the version hash for the coding plan template. + * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @param template - The template to compute version for + * @returns Hexadecimal string representing the template version + */ +export function computeCodingPlanVersion(template: CodingPlanTemplate): string { + const templateString = JSON.stringify(template); + return createHash('sha256').update(templateString).digest('hex'); +} + +/** + * Generate the complete coding plan template for a specific region. + * China region uses legacy description to maintain backward compatibility. + * Global region uses new description with region indicator. + * @param region - The region to generate template for + * @returns Complete model configuration array for the region + */ +export function generateCodingPlanTemplate( + region: CodingPlanRegion, +): CodingPlanTemplate { + if (region === CodingPlanRegion.CHINA) { + return [ + { + id: 'qwen3.5-plus', + name: '[ModelStudio Coding Plan] qwen3.5-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3.6-plus', + name: '[ModelStudio Coding Plan] qwen3.6-plus', + description: 'Currently available to Pro subscribers only.', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'glm-5', + name: '[ModelStudio Coding Plan] glm-5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + { + id: 'kimi-k2.5', + name: '[ModelStudio Coding Plan] kimi-k2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[ModelStudio Coding Plan] MiniMax-M2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 196608, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[ModelStudio Coding Plan] qwen3-coder-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[ModelStudio Coding Plan] qwen3-coder-next', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[ModelStudio Coding Plan] qwen3-max-2026-01-23', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[ModelStudio Coding Plan] glm-4.7', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + ]; + } + + // Global region + return [ + { + id: 'qwen3.5-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3.5-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3.6-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3.6-plus', + description: 'Currently available to Pro subscribers only.', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-next', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-max-2026-01-23', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[ModelStudio Coding Plan for Global/Intl] glm-4.7', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + { + id: 'glm-5', + name: '[ModelStudio Coding Plan for Global/Intl] glm-5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[ModelStudio Coding Plan for Global/Intl] MiniMax-M2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 196608, + }, + }, + { + id: 'kimi-k2.5', + name: '[ModelStudio Coding Plan for Global/Intl] kimi-k2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + ]; +} + +/** + * Get the complete configuration for a specific region. + * @param region - The region to use + * @returns Object containing template, baseUrl, and version + */ +export function getCodingPlanConfig(region: CodingPlanRegion) { + const template = generateCodingPlanTemplate(region); + const baseUrl = + region === CodingPlanRegion.CHINA + ? 'https://coding.dashscope.aliyuncs.com/v1' + : 'https://coding-intl.dashscope.aliyuncs.com/v1'; + return { + template, + baseUrl, + version: computeCodingPlanVersion(template), + }; +} + +/** + * Get all unique base URLs for coding plan (used for filtering/config detection). + * @returns Array of base URLs + */ +export function getCodingPlanBaseUrls(): string[] { + return [ + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://coding-intl.dashscope.aliyuncs.com/v1', + ]; +} + +/** + * Check if a config belongs to Coding Plan (any region). + * Returns the region if matched, or false if not a Coding Plan config. + * @param baseUrl - The baseUrl to check + * @param envKey - The envKey to check + * @returns The region if matched, false otherwise + */ +export function isCodingPlanConfig( + baseUrl: string | undefined, + envKey: string | undefined, +): CodingPlanRegion | false { + if (!baseUrl || !envKey) return false; + if (envKey !== CODING_PLAN_ENV_KEY) return false; + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + return false; +} + +/** + * Get region from baseUrl. + * @param baseUrl - The baseUrl to check + * @returns The region if matched, null otherwise + */ +export function getRegionFromBaseUrl( + baseUrl: string | undefined, +): CodingPlanRegion | null { + if (!baseUrl) return null; + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + return null; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e672e4adf..99837225f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,19 @@ export { validateModelConfig, } from './models/index.js'; +// Coding Plan constants +export { + CodingPlanRegion, + type CodingPlanTemplate, + CODING_PLAN_ENV_KEY, + computeCodingPlanVersion, + generateCodingPlanTemplate, + getCodingPlanConfig, + getCodingPlanBaseUrls, + isCodingPlanConfig, + getRegionFromBaseUrl, +} from './constants/codingPlan.js'; + // Output formatting export * from './output/json-formatter.js'; export * from './output/types.js'; diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 2a344109c..6abe3e6a5 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -218,6 +218,9 @@ async function main() { logLevel: 'silent', plugins: [reactDedupPlugin, cssInjectPlugin, esbuildProblemMatcherPlugin], jsx: 'automatic', // Use new JSX transform (React 17+) + loader: { + '.png': 'dataurl', + }, define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"', }, diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index c426c9d12..34c1f73de 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -111,8 +111,8 @@ "icon": "./assets/icon.png" }, { - "command": "qwen-code.login", - "title": "Qwen Code: Login" + "command": "qwen-code.auth", + "title": "Qwen Code: Auth" }, { "command": "qwen-code.focusChat", @@ -138,8 +138,7 @@ "when": "qwen.diff.isVisible" }, { - "command": "qwen-code.login", - "when": "false" + "command": "qwen-code.auth" } ], "editor/title": [ @@ -159,6 +158,45 @@ } ] }, + "configuration": { + "title": "Qwen Code", + "properties": { + "qwen-code.provider": { + "order": 0, + "type": "string", + "enum": [ + "coding-plan", + "api-key" + ], + "enumDescriptions": [ + "Alibaba Cloud Coding Plan — configurable from VS Code Settings", + "Configured via Qwen Code: Auth or the onboarding button" + ], + "default": "coding-plan", + "markdownDescription": "**Coding Plan**: enter API Key + Region here to sync `~/.qwen/settings.json`.\n\n**API Key**: use **Qwen Code: Auth** or the onboarding button to configure ModelStudio or custom OpenAI-compatible providers." + }, + "qwen-code.apiKey": { + "order": 1, + "type": "string", + "default": "", + "markdownDescription": "API key used for **Coding Plan** settings sync. For **API Key** providers, configure the full provider details through **Qwen Code: Auth**." + }, + "qwen-code.codingPlanRegion": { + "order": 2, + "type": "string", + "enum": [ + "china", + "global" + ], + "enumDescriptions": [ + "China — 阿里云百炼 (aliyun.com)", + "Global — Alibaba Cloud (alibabacloud.com)" + ], + "default": "china", + "markdownDescription": "Region for Coding Plan. _(Only used when Provider is `coding-plan`)_" + } + } + }, "keybindings": [ { "command": "qwen.diff.accept", diff --git a/packages/vscode-ide-companion/src/assets.d.ts b/packages/vscode-ide-companion/src/assets.d.ts new file mode 100644 index 000000000..1c5923252 --- /dev/null +++ b/packages/vscode-ide-companion/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: string; + export default value; +} diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts index 526b7ffea..e2ffe203e 100644 --- a/packages/vscode-ide-companion/src/commands/index.test.ts +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + authCommand, focusChatCommand, openNewChatTabCommand, registerNewCommands, @@ -70,6 +71,7 @@ describe('registerNewCommands', () => { const provider = { show: vi.fn().mockResolvedValue(undefined), createNewSession: vi.fn().mockResolvedValue(undefined), + startInteractiveAuth: vi.fn().mockResolvedValue(undefined), setInitialModelId: vi.fn(), }; @@ -90,6 +92,27 @@ describe('registerNewCommands', () => { expect(provider.setInitialModelId).toHaveBeenCalledWith('glm-5'); }); + it('auth opens the interactive provider setup flow instead of VS Code settings', async () => { + const provider = { + show: vi.fn().mockResolvedValue(undefined), + startInteractiveAuth: vi.fn().mockResolvedValue(undefined), + }; + + registerNewCommands( + context as never, + log, + diffManager as never, + () => [provider as never], + vi.fn(() => provider as never), + ); + + await getRegisteredHandler(authCommand)(); + + expect(provider.show).toHaveBeenCalledTimes(1); + expect(provider.startInteractiveAuth).toHaveBeenCalledTimes(1); + expect(executeCommand).not.toHaveBeenCalled(); + }); + it('focusChat focuses the secondary sidebar when it is supported', async () => { registerNewCommands( context as never, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 0fbe2a654..959946b77 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -19,7 +19,7 @@ export const runQwenCodeCommand = 'qwen-code.runQwenCode'; export const showDiffCommand = 'qwenCode.showDiff'; export const openChatCommand = 'qwen-code.openChat'; export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; -export const loginCommand = 'qwen-code.login'; +export const authCommand = 'qwen-code.auth'; export const focusChatCommand = 'qwen-code.focusChat'; export const newConversationCommand = 'qwen-code.newConversation'; export const showLogsCommand = 'qwen-code.showLogs'; @@ -101,15 +101,15 @@ export function registerNewCommands( ); disposables.push( - vscode.commands.registerCommand(loginCommand, async () => { + vscode.commands.registerCommand(authCommand, async () => { const providers = getWebViewProviders(); - if (providers.length > 0) { - await providers[providers.length - 1].forceReLogin(); - } else { - vscode.window.showInformationMessage( - 'Please open Qwen Code chat first before logging in.', - ); - } + const provider = + providers.length > 0 + ? providers[providers.length - 1] + : createWebViewProvider(); + + await provider.show(); + await provider.startInteractiveAuth(); }), ); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts new file mode 100644 index 000000000..306e78cf0 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetGlobalSettingsPath } = vi.hoisted(() => ({ + mockGetGlobalSettingsPath: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { + ...actual.Storage, + getGlobalSettingsPath: mockGetGlobalSettingsPath, + }, + }; +}); + +import { CODING_PLAN_ENV_KEY, AuthType } from '@qwen-code/qwen-code-core'; +import { + readQwenSettingsForVSCode, + writeCodingPlanConfig, + writeModelProvidersConfig, +} from './settingsWriter.js'; + +describe('settingsWriter', () => { + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-vscode-settings-')); + settingsPath = path.join(tempDir, '.qwen', 'settings.json'); + mockGetGlobalSettingsPath.mockReturnValue(settingsPath); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('clears stale coding plan metadata when writing api-key providers', () => { + writeCodingPlanConfig('china', 'coding-plan-key'); + + writeModelProvidersConfig({ + apiKey: 'manual-key', + modelProviders: { + 'gpt-4o': 'https://api.openai.com/v1', + }, + activeModel: 'gpt-4o', + }); + + const settings = JSON.parse( + fs.readFileSync(settingsPath, 'utf-8'), + ) as Record; + const env = settings.env as Record; + const modelProviders = settings.modelProviders as Record; + const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array< + Record + >; + + expect(env.OPENAI_API_KEY).toBe('manual-key'); + expect(env[CODING_PLAN_ENV_KEY]).toBeUndefined(); + expect(settings.codingPlan).toBeUndefined(); + expect(settings.model).toEqual({ name: 'gpt-4o' }); + // The new entry must be present + expect(openaiModels[0]).toEqual({ + id: 'gpt-4o', + name: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }); + // Non-target entries (Coding Plan) are preserved, not silently deleted + const preserved = openaiModels.filter( + (m) => m.envKey === CODING_PLAN_ENV_KEY, + ); + expect(preserved.length).toBeGreaterThan(0); + }); + + it('reads an api-key configuration after switching away from coding plan', () => { + writeCodingPlanConfig('china', 'coding-plan-key'); + + writeModelProvidersConfig({ + apiKey: 'manual-key', + modelProviders: { + 'gpt-4o': 'https://api.openai.com/v1', + }, + activeModel: 'gpt-4o', + }); + + expect(readQwenSettingsForVSCode()).toEqual({ + provider: 'api-key', + apiKey: 'manual-key', + codingPlanRegion: 'china', + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts new file mode 100644 index 000000000..43d83b8aa --- /dev/null +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -0,0 +1,294 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Settings writer for VSCode extension. + * Handles bidirectional sync between VSCode Settings and ~/.qwen/settings.json. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + AuthType, + Storage, + CodingPlanRegion, + CODING_PLAN_ENV_KEY, + getCodingPlanConfig, +} from '@qwen-code/qwen-code-core'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Model providers as key-value map: modelId → baseUrl. + * This is the format VSCode Settings UI can render as an editable table. + */ +export type VSCodeModelProviders = Record; + +/** + * Values extracted from ~/.qwen/settings.json for populating VSCode Settings. + */ +export interface QwenSettingsForVSCode { + provider: 'coding-plan' | 'api-key'; + apiKey: string; + codingPlanRegion: 'china' | 'global'; +} + +// --------------------------------------------------------------------------- +// Low-level read/write helpers +// --------------------------------------------------------------------------- + +/** + * Read ~/.qwen/settings.json. Returns {} if missing or invalid. + */ +function readSettings(): Record { + try { + const content = fs.readFileSync(Storage.getGlobalSettingsPath(), 'utf-8'); + return JSON.parse(content) as Record; + } catch { + return {}; + } +} + +/** + * Write ~/.qwen/settings.json (creates dir if needed). + */ +function writeSettings(settings: Record): void { + const settingsPath = Storage.getGlobalSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); +} + +/** + * Ensure nested objects exist at the given key path. + */ +function ensureNestedObject( + obj: Record, + ...keys: string[] +): Record { + let current = obj; + for (const key of keys) { + if (!current[key] || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key] as Record; + } + return current; +} + +/** + * Find OpenAI-compatible model entries from modelProviders. + * CLI uses AuthType.USE_OPENAI ('openai') as the key, but some legacy + * configs may use other keys. Check both. + */ +function findOpenaiModels( + modelProviders: Record | undefined, +): Array> { + if (!modelProviders) { + return []; + } + for (const key of [AuthType.USE_OPENAI, 'use_openai']) { + const arr = modelProviders[key]; + if (Array.isArray(arr) && arr.length > 0) { + return arr as Array>; + } + } + return []; +} + +// --------------------------------------------------------------------------- +// Write: VSCode Settings → ~/.qwen/settings.json +// --------------------------------------------------------------------------- + +/** + * Write Coding Plan configuration to ~/.qwen/settings.json. + * Auto-injects model providers from the regional template, + * preserving any existing non-Coding-Plan entries. + * + * @returns The injected models as a VSCode key-value map (modelId → baseUrl) + */ +export function writeCodingPlanConfig( + region: 'china' | 'global', + apiKey: string, +): VSCodeModelProviders { + const settings = readSettings(); + const codingRegion = + region === 'global' ? CodingPlanRegion.GLOBAL : CodingPlanRegion.CHINA; + const planConfig = getCodingPlanConfig(codingRegion); + + // Auth + const auth = ensureNestedObject(settings, 'security', 'auth'); + auth.selectedType = AuthType.USE_OPENAI; + + // API key + const env = ensureNestedObject(settings, 'env'); + env[CODING_PLAN_ENV_KEY] = apiKey; + + // Model providers — merge Coding Plan templates with existing non-CP entries + const providers = ensureNestedObject(settings, 'modelProviders'); + const existing = findOpenaiModels( + settings.modelProviders as Record, + ); + const nonCodingPlan = existing.filter( + (e) => e.envKey !== CODING_PLAN_ENV_KEY, + ); + providers[AuthType.USE_OPENAI] = [...planConfig.template, ...nonCodingPlan]; + + // Coding Plan metadata + settings.codingPlan = { region: codingRegion, version: planConfig.version }; + + // Default model + const defaultModelId = planConfig.template[0]?.id ?? 'qwen3.5-plus'; + settings.model = { name: defaultModelId }; + + writeSettings(settings); + + // Return key-value map for VSCode settings + const result: VSCodeModelProviders = {}; + for (const m of planConfig.template) { + result[m.id] = m.baseUrl || ''; + } + return result; +} + +/** + * Write model providers from VSCode Settings (key-value map) to ~/.qwen/settings.json. + * Used when provider = "api-key" and user edits the modelProviders map. + * + * @param params.apiKey - The API key + * @param params.modelProviders - Map of modelId → baseUrl + * @param params.activeModel - Currently selected model ID + */ +export function writeModelProvidersConfig(params: { + apiKey: string; + modelProviders: VSCodeModelProviders; + activeModel: string; +}): void { + const settings = readSettings(); + + // Auth + const auth = ensureNestedObject(settings, 'security', 'auth'); + auth.selectedType = AuthType.USE_OPENAI; + + // API key + const env = ensureNestedObject(settings, 'env'); + env['OPENAI_API_KEY'] = params.apiKey; + delete env[CODING_PLAN_ENV_KEY]; + + // Convert key-value map to CLI's array format and merge with existing + // non-target entries so reconfiguring one provider doesn't silently + // delete others (e.g. Coding Plan entries with a different envKey). + const providers = ensureNestedObject(settings, 'modelProviders'); + const modelArray = Object.entries(params.modelProviders).map( + ([id, baseUrl]) => ({ + id, + name: id, + baseUrl: baseUrl || 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }), + ); + const existing = findOpenaiModels( + settings.modelProviders as Record, + ); + const nonTarget = existing.filter((e) => e.envKey !== 'OPENAI_API_KEY'); + providers[AuthType.USE_OPENAI] = [...modelArray, ...nonTarget]; + + // Active model + if (params.activeModel) { + settings.model = { name: params.activeModel }; + } + + delete settings.codingPlan; + + writeSettings(settings); +} + +// --------------------------------------------------------------------------- +// Read: ~/.qwen/settings.json → VSCode Settings +// --------------------------------------------------------------------------- + +/** + * Read ~/.qwen/settings.json and extract values for VSCode Settings UI. + * Returns null if no valid configuration found. + */ +export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null { + const settings = readSettings(); + + const security = settings.security as Record | undefined; + const auth = security?.auth as Record | undefined; + if (!auth?.selectedType) { + return null; + } + + const env = (settings.env ?? {}) as Record; + const codingPlan = settings.codingPlan as Record | undefined; + + // Determine if this is a Coding Plan setup + const hasCodingPlanKey = !!env[CODING_PLAN_ENV_KEY]; + const hasCodingPlanRegion = !!codingPlan?.region; + + if (hasCodingPlanKey && hasCodingPlanRegion) { + return { + provider: 'coding-plan', + apiKey: env[CODING_PLAN_ENV_KEY] || '', + codingPlanRegion: (codingPlan?.region as 'china' | 'global') || 'china', + }; + } + + // Non-Coding-Plan — find API key from model providers + const modelProviders = settings.modelProviders as + | Record + | undefined; + const openaiModels = findOpenaiModels(modelProviders); + const firstEnvKey = (openaiModels[0]?.envKey as string) || 'OPENAI_API_KEY'; + const apiKey = env[firstEnvKey] || ''; + + if (!apiKey) { + return null; + } + + return { + provider: 'api-key', + apiKey, + codingPlanRegion: 'china', + }; +} + +/** + * Clear persisted auth credentials from ~/.qwen/settings.json. + * Removes API keys, auth type selection, and coding plan metadata + * so runtime state matches the cleared VS Code settings. + */ +export function clearPersistedAuth(): void { + try { + const settings = readSettings(); + + // Remove auth type selection + const security = settings.security as Record | undefined; + if (security?.auth) { + delete (security.auth as Record).selectedType; + } + + // Remove API keys + const env = settings.env as Record | undefined; + if (env) { + delete env[CODING_PLAN_ENV_KEY]; + delete env['OPENAI_API_KEY']; + } + + // Remove coding plan metadata + delete settings.codingPlan; + + writeSettings(settings); + } catch (error) { + console.error( + '[settingsWriter] Failed to clear persisted auth credentials:', + error, + ); + } +} diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index a2c685e18..8e3a32263 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -12,7 +12,11 @@ import type { ApprovalModeValue } from './approvalModeValueTypes.js'; // Private / Qwen-specific types (not part of ACP spec) // --------------------------------------------------------------------------- -export const authMethod = 'qwen-oauth'; +// Default auth method for ACP authenticate requests. +// Value matches AuthType.USE_OPENAI from @qwen-code/qwen-code-core. +// Cannot import directly because this file is used in the webview bundle +// where core (Node.js-only) is excluded as external. +export const authMethod = 'openai'; /** * Authenticate update notification (Qwen extension, not ACP spec). diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts index 601848c6a..9f277dde1 100644 --- a/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts +++ b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts @@ -39,6 +39,9 @@ describe('imageSupport browser bundling', () => { write: false, logLevel: 'silent', external: ['@qwen-code/qwen-code-core'], + loader: { + '.png': 'dataurl', + }, }); const output = result.outputFiles[0]?.text ?? ''; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8a95b8644..59ce30cd0 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -281,9 +281,9 @@ export const App: React.FC = () => { // Account group const accountGroupItems: CompletionItem[] = [ { - id: 'login', - label: 'Login', - description: 'Login to Qwen Code', + id: 'auth', + label: '/auth', + description: 'Configure Coding Plan or API Key', type: 'command', group: 'Account', }, @@ -697,9 +697,9 @@ export const App: React.FC = () => { } }; - if (itemId === 'login') { + if (itemId === 'auth') { clearTriggerText(); - vscode.postMessage({ type: 'login', data: {} }); + vscode.postMessage({ type: 'auth', data: {} }); completion.closeCompletion(); return; } @@ -1011,16 +1011,23 @@ export const App: React.FC = () => { > {!hasContent && !isLoading ? ( isAuthenticated === false ? ( - { - vscode.postMessage({ type: 'login', data: {} }); - messageHandling.setWaitingForResponse( - 'Logging in to Qwen Code...', - ); - }} - /> + ) : isAuthenticated === null ? ( - +
+ + + Preparing Qwen Code... + +
) : ( ) diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx new file mode 100644 index 000000000..31a20804a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; + +vi.mock('./ProviderSetupForm.js', () => ({ + ProviderSetupForm: () => , +})); + +import { Onboarding } from './Onboarding.js'; + +describe('Onboarding', () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + document.body.removeAttribute('data-extension-uri'); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; + }); + + it('renders the logo without requiring an extension URI on the body', () => { + act(() => { + root?.render(); + }); + + const logo = container?.querySelector('img[alt="Qwen Code"]'); + + expect(logo).toBeTruthy(); + expect(logo?.getAttribute('src')).toBeTruthy(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx index b67893097..97d027346 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -3,24 +3,74 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * VSCode-specific Onboarding adapter - * Uses webui Onboarding component with platform-specific icon URL + * VSCode-specific Onboarding page. + * Vertically centered welcome card with provider setup trigger. */ import type { FC } from 'react'; -import { Onboarding as BaseOnboarding } from '@qwen-code/webui'; -import { generateIconUrl } from '../../utils/resourceUrl.js'; - -interface OnboardingPageProps { - onLogin: () => void; -} +// eslint-disable-next-line import/no-internal-modules -- bundle the webview logo as a data URL +import iconUrl from '../../../../assets/icon.png'; +import { ProviderSetupForm } from './ProviderSetupForm.js'; /** - * VSCode Onboarding wrapper - * Provides platform-specific icon URL to the webui Onboarding component + * VSCode Onboarding page. */ -export const Onboarding: FC = ({ onLogin }) => { - const iconUri = generateIconUrl('icon.png'); +export const Onboarding: FC = () => ( +
+ {/* Logo + title block — sits above the card for visual breathing room */} +
+ Qwen Code +
+

+ Qwen Code +

+

+ AI-powered coding assistant for your editor +

+
+
- return ; -}; + {/* Setup card */} +
+

+ Connect a model provider to get started +

+ +
+ + {/* Subtle hint below the card */} +

+ Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and + OpenAI-compatible endpoints +

+
+ ); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx new file mode 100644 index 000000000..970f0ec1a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; + +const { mockPostMessage } = vi.hoisted(() => ({ + mockPostMessage: vi.fn(), +})); + +vi.mock('../../hooks/useVSCode.js', () => ({ + useVSCode: () => ({ + postMessage: mockPostMessage, + getState: vi.fn(), + setState: vi.fn(), + }), +})); + +import { ProviderSetupForm } from './ProviderSetupForm.js'; + +describe('ProviderSetupForm', () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; + }); + + it('leaves connecting state when auth flow is cancelled', () => { + act(() => { + root?.render(); + }); + + const button = container?.querySelector('button'); + expect(button).toBeTruthy(); + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'auth' }); + expect(container?.textContent).toContain('Connecting...'); + expect(button?.hasAttribute('disabled')).toBe(true); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authCancelled' }, + }), + ); + }); + + expect(container?.textContent).toContain('Get Started'); + expect(button?.hasAttribute('disabled')).toBe(false); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx new file mode 100644 index 000000000..63d284e22 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Provider Setup — triggers the auth interactive flow (QuickPick + InputBox). + */ + +import { useState, useEffect, type FC } from 'react'; +import { useVSCode } from '../../hooks/useVSCode.js'; + +/** + * Small rotating spinner for loading states. + */ +const Spinner: FC<{ size?: number }> = ({ size = 14 }) => ( + +); + +/** + * ProviderSetupForm — Single button that launches the interactive auth flow. + */ +export const ProviderSetupForm: FC = () => { + const vscode = useVSCode(); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const handler = (event: MessageEvent) => { + const msg = event.data; + if (msg?.type === 'authError' || msg?.type === 'agentConnectionError') { + setIsConnecting(false); + setError( + msg.data?.message || 'Connection failed. Check your settings.', + ); + } + if (msg?.type === 'authCancelled') { + setIsConnecting(false); + setError(null); + } + if (msg?.type === 'authSuccess' || msg?.type === 'agentConnected') { + setIsConnecting(false); + setError(null); + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, []); + + const handleGetStarted = () => { + setError(null); + setIsConnecting(true); + vscode.postMessage({ type: 'auth' }); + }; + + return ( +
+ + + {error && ( +
+ {error} +
+ )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx index bc912e367..b9b6e855e 100644 --- a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx +++ b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx @@ -97,10 +97,10 @@ export const VSCodePlatformProvider: FC = ({ }); }, [vscode]); - // Login handler + // Auth handler const login = useCallback(() => { vscode.postMessage({ - type: 'login', + type: 'auth', data: {}, }); }, [vscode]); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts new file mode 100644 index 000000000..85a7512a6 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockShowInputBox, mockShowQuickPick } = vi.hoisted(() => ({ + mockShowInputBox: vi.fn(), + mockShowQuickPick: vi.fn(), +})); + +vi.mock('vscode', () => ({ + window: { + showQuickPick: mockShowQuickPick, + showInputBox: mockShowInputBox, + }, +})); + +import { AuthMessageHandler } from './AuthMessageHandler.js'; + +describe('AuthMessageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends authCancelled when the provider picker is dismissed', async () => { + mockShowQuickPick.mockResolvedValue(undefined); + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ type: 'auth' }); + + expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); + }); + + it('sends authCancelled when the api key input is dismissed mid-flow', async () => { + mockShowQuickPick + .mockResolvedValueOnce({ value: 'coding-plan' }) + .mockResolvedValueOnce({ value: 'china' }); + mockShowInputBox.mockResolvedValue(undefined); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ type: 'auth' }); + + expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index 65aae6d00..c55513660 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -13,22 +13,30 @@ import { getErrorMessage } from '../../utils/errorMessage.js'; * Handles all authentication-related messages */ export class AuthMessageHandler extends BaseMessageHandler { - private loginHandler: (() => Promise) | null = null; + private authInteractiveHandler: + | (( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise) + | null = null; canHandle(messageType: string): boolean { - return ['login', 'getAccountInfo'].includes(messageType); + return ['auth', 'getAccountInfo'].includes(messageType); } async handle(message: { type: string; data?: unknown }): Promise { switch (message.type) { - case 'login': - await this.handleLogin(); + case 'auth': + await this.handleAuthInteractive(); break; - case 'getAccountInfo': { + case 'getAccountInfo': await this.handleGetAccountInfo(); break; - } default: console.warn( @@ -40,14 +48,23 @@ export class AuthMessageHandler extends BaseMessageHandler { } /** - * Set login handler + * Set auth interactive handler — interactive auth flow. */ - setLoginHandler(handler: () => Promise): void { - this.loginHandler = handler; + setAuthInteractiveHandler( + handler: ( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise, + ): void { + this.authInteractiveHandler = handler; } /** - * Handle getAccountInfo request - queries ACP for live account info + * Handle getAccountInfo request */ private async handleGetAccountInfo(): Promise { try { @@ -71,45 +88,300 @@ export class AuthMessageHandler extends BaseMessageHandler { } } - /** - * Handle login request - */ - private async handleLogin(): Promise { - try { - console.log('[AuthMessageHandler] Login requested'); - console.log( - '[AuthMessageHandler] Login handler available:', - !!this.loginHandler, - ); + // --------------------------------------------------------------------------- + // auth: Interactive auth flow (mirrors CLI's /auth) + // --------------------------------------------------------------------------- - // Direct login without additional confirmation - if (this.loginHandler) { - console.log('[AuthMessageHandler] Calling login handler'); - await this.loginHandler(); - console.log( - '[AuthMessageHandler] Login handler completed successfully', - ); + // Alibaba Standard API Key region endpoints + private static readonly ALIBABA_STANDARD_ENDPOINTS: Record = { + 'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'sg-singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + 'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + 'cn-hongkong': + 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + }; + + /** + * Notify the webview that the interactive auth flow was dismissed. + */ + private notifyAuthCancelled(): void { + this.sendToWebView({ type: 'authCancelled' }); + } + + /** + * Helper: show a QuickPick and return the selected item's `value`. + * Returns undefined if the user cancels. + */ + private async pick( + items: Array<{ label: string; description?: string; value: T }>, + title: string, + placeHolder: string, + ): Promise { + const choice = await vscode.window.showQuickPick(items, { + title, + placeHolder, + }); + if (!choice) { + this.notifyAuthCancelled(); + return undefined; + } + return (choice as { value: T }).value; + } + + /** + * Helper: show an InputBox. Returns undefined if the user cancels. + */ + private async input(opts: { + title: string; + prompt: string; + placeHolder?: string; + value?: string; + password?: boolean; + required?: boolean; + }): Promise { + const value = await vscode.window.showInputBox({ + title: opts.title, + prompt: opts.prompt, + placeHolder: opts.placeHolder, + value: opts.value, + password: opts.password ?? false, + validateInput: opts.required + ? (v) => (!v?.trim() ? 'This field is required' : null) + : undefined, + }); + if (value === undefined) { + this.notifyAuthCancelled(); + return undefined; + } + return value; + } + + /** + * Handle auth — full interactive auth flow. + * + * Tree (mirrors CLI AuthDialog): + * |- Coding Plan -> Region (China/Global) -> API Key -> done + * \- API Key + * |- Alibaba Standard -> Region (4 regions) -> API Key -> Model IDs -> done + * \- Custom -> Base URL -> API Key -> Model -> done + */ + private async handleAuthInteractive(): Promise { + try { + // Main menu + const provider = await this.pick( + [ + { + label: 'Alibaba Cloud Coding Plan', + description: + 'Paid · Up to 6,000 requests/5 hrs · All Coding Plan Models', + value: 'coding-plan' as const, + }, + { + label: 'API Key', + description: 'Bring your own API key', + value: 'api-key' as const, + }, + ], + 'Qwen Code: Auth', + 'Select authentication method', + ); + if (!provider) { + return; + } + + if (provider === 'coding-plan') { + await this.authCodingPlan(); } else { - console.log('[AuthMessageHandler] Using fallback login method'); - // Fallback: show message and use command - vscode.window.showInformationMessage( - 'Please wait while we connect to Qwen Code...', - ); - await vscode.commands.executeCommand('qwen-code.login'); + await this.authApiKey(); } } catch (error) { const errorMsg = getErrorMessage(error); - console.error('[AuthMessageHandler] Login failed:', error); - console.error( - '[AuthMessageHandler] Error stack:', - error instanceof Error ? error.stack : 'N/A', - ); + console.error('[AuthMessageHandler] auth failed:', error); this.sendToWebView({ - type: 'loginError', - data: { - message: `Login failed: ${errorMsg}`, - }, + type: 'authError', + data: { message: `Auth failed: ${errorMsg}` }, }); } } + + /** + * Coding Plan: region -> API key -> connect. + */ + private async authCodingPlan(): Promise { + const region = await this.pick( + [ + { + label: '中国 (China)', + description: '阿里云百炼 — aliyun.com', + value: 'china' as const, + }, + { + label: 'Global', + description: 'Alibaba Cloud — alibabacloud.com', + value: 'global' as const, + }, + ], + 'Qwen Code: Coding Plan Region', + 'Select region', + ); + if (!region) { + return; + } + + const apiKey = await this.input({ + title: 'Qwen Code: API Key', + prompt: 'Enter your Coding Plan API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler('coding-plan', region, apiKey); + } + } + + /** + * API Key: select type -> Alibaba Standard or Custom. + */ + private async authApiKey(): Promise { + const keyType = await this.pick( + [ + { + label: 'Alibaba Cloud ModelStudio Standard API Key', + description: 'Quick setup for Model Studio (China/International)', + value: 'alibaba-standard' as const, + }, + { + label: 'Custom API Key', + description: + 'For other OpenAI / Anthropic / Gemini-compatible providers', + value: 'custom' as const, + }, + ], + 'Qwen Code: Select API Key Type', + 'Select API key type', + ); + if (!keyType) { + return; + } + + if (keyType === 'alibaba-standard') { + await this.authAlibabaStandard(); + } else { + await this.authCustom(); + } + } + + /** + * Alibaba Standard: region -> API key -> model IDs -> connect. + */ + private async authAlibabaStandard(): Promise { + const endpoints = AuthMessageHandler.ALIBABA_STANDARD_ENDPOINTS; + + const region = await this.pick( + Object.entries(endpoints).map(([key, endpoint]) => ({ + label: + key === 'cn-beijing' + ? 'China (Beijing)' + : key === 'sg-singapore' + ? 'Singapore' + : key === 'us-virginia' + ? 'US (Virginia)' + : 'China (Hong Kong)', + description: `Endpoint: ${endpoint}`, + value: key, + })), + 'Qwen Code: Select Region', + 'Select region for Alibaba Cloud ModelStudio', + ); + if (!region) { + return; + } + + const apiKey = await this.input({ + title: 'Qwen Code: API Key', + prompt: 'Enter your Alibaba Cloud ModelStudio API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + const modelIds = await this.input({ + title: 'Qwen Code: Model IDs', + prompt: 'Enter model IDs (comma-separated)', + placeHolder: 'qwen3.5-plus,glm-5,kimi-k2.5', + value: 'qwen3.5-plus', + required: true, + }); + if (!modelIds) { + return; + } + + const baseUrl = endpoints[region] || endpoints['cn-beijing']; + const firstModel = modelIds.split(',')[0]?.trim() || 'qwen3.5-plus'; + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler( + 'alibaba-standard', + region, + apiKey, + baseUrl, + firstModel, + modelIds, + ); + } + } + + /** + * Custom: base URL -> API key -> model -> connect. + */ + private async authCustom(): Promise { + const baseUrl = await this.input({ + title: 'Qwen Code: Base URL', + prompt: 'Enter API base URL', + placeHolder: 'https://api.openai.com/v1', + value: 'https://api.openai.com/v1', + }); + if (baseUrl === undefined) { + return; + } + + const apiKey = await this.input({ + title: 'Qwen Code: API Key', + prompt: 'Enter your API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + const model = await this.input({ + title: 'Qwen Code: Model', + prompt: 'Enter model name', + placeHolder: 'gpt-4o', + required: true, + }); + if (!model) { + return; + } + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler( + 'api-key', + undefined, + apiKey, + baseUrl, + model, + ); + } + } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index 2f1b862cc..e7d40fd47 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -165,11 +165,26 @@ export class MessageRouter { } /** - * Set login handler + * Set auth interactive handler — interactive auth flow. + * Also registers the handler on the session handler so + * "Configure" prompts in session flows trigger the interactive flow. */ - setLoginHandler(handler: () => Promise): void { - this.authHandler.setLoginHandler(handler); - this.sessionHandler?.setLoginHandler?.(handler); + setAuthInteractiveHandler( + handler: ( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise, + ): void { + this.authHandler.setAuthInteractiveHandler(handler); + // SessionMessageHandler's authHandler is a simple () => Promise. + // Wrap so "Configure" prompts trigger the full interactive auth QuickPick. + this.sessionHandler?.setAuthHandler?.(() => + this.authHandler.handle({ type: 'auth' }), + ); } /** diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index ea94c10c4..2d8d29168 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -22,7 +22,7 @@ import { getErrorMessage } from '../../utils/errorMessage.js'; */ export class SessionMessageHandler extends BaseMessageHandler { private currentStreamContent = ''; - private loginHandler: (() => Promise) | null = null; + private authHandler: (() => Promise) | null = null; private isTitleSet = false; // Flag to track if title has been set canHandle(messageType: string): boolean { @@ -42,10 +42,10 @@ export class SessionMessageHandler extends BaseMessageHandler { } /** - * Set login handler + * Set auth handler */ - setLoginHandler(handler: () => Promise): void { - this.loginHandler = handler; + setAuthHandler(handler: () => Promise): void { + this.authHandler = handler; } async handle(message: { type: string; data?: unknown }): Promise { @@ -223,16 +223,16 @@ export class SessionMessageHandler extends BaseMessageHandler { } /** - * Prompt user to login and invoke the registered login handler/command. - * Returns true if a login was initiated. + * Prompt user to authenticate and invoke the registered auth handler/command. + * Returns true if authentication was initiated. */ - private async promptLogin(message: string): Promise { - const result = await vscode.window.showWarningMessage(message, 'Login Now'); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); + private async promptAuth(message: string): Promise { + const result = await vscode.window.showWarningMessage(message, 'Configure'); + if (result === 'Configure') { + if (this.authHandler) { + await this.authHandler(); } else { - await vscode.commands.executeCommand('qwen-code.login'); + await vscode.commands.executeCommand('qwen-code.auth'); } return true; } @@ -240,25 +240,25 @@ export class SessionMessageHandler extends BaseMessageHandler { } /** - * Prompt user to login or view offline. Returns 'login', 'offline', or 'dismiss'. - * When login is chosen, it triggers the login handler/command. + * Prompt user to authenticate or view offline. Returns 'auth', 'offline', or 'dismiss'. + * When configure is chosen, it triggers the auth handler/command. */ - private async promptLoginOrOffline( + private async promptAuthOrOffline( message: string, - ): Promise<'login' | 'offline' | 'dismiss'> { + ): Promise<'auth' | 'offline' | 'dismiss'> { const selection = await vscode.window.showWarningMessage( message, - 'Login Now', + 'Configure', 'View Offline', ); - if (selection === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); + if (selection === 'Configure') { + if (this.authHandler) { + await this.authHandler(); } else { - await vscode.commands.executeCommand('qwen-code.login'); + await vscode.commands.executeCommand('qwen-code.auth'); } - return 'login'; + return 'auth'; } if (selection === 'View Offline') { return 'offline'; @@ -270,7 +270,7 @@ export class SessionMessageHandler extends BaseMessageHandler { return getErrorMessage(error); } - private shouldPromptLogin(error: unknown): boolean { + private shouldPromptAuth(error: unknown): boolean { return isAuthenticationRequiredError(error); } @@ -424,8 +424,10 @@ export class SessionMessageHandler extends BaseMessageHandler { if (!this.agentManager.isConnected) { console.warn('[SessionMessageHandler] Agent not connected'); - // Show non-modal notification with Login button - await this.promptLogin('You need to login first to use Qwen Code.'); + // Show non-modal notification with Configure button + await this.promptAuth( + 'You need to configure your provider to use Qwen Code.', + ); return; } @@ -441,9 +443,9 @@ export class SessionMessageHandler extends BaseMessageHandler { createErr, ); const errorMsg = this.getErrorMessage(createErr); - if (this.shouldPromptLogin(createErr)) { - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + if (this.shouldPromptAuth(createErr)) { + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to continue using Qwen Code.', ); return; } @@ -522,17 +524,17 @@ export class SessionMessageHandler extends BaseMessageHandler { // Check for session not found error and handle it appropriately if ( errorMsg.includes('Session not found') || - this.shouldPromptLogin(error) + this.shouldPromptAuth(error) ) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to continue using Qwen Code.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); this.sendStreamEnd('session_expired', myRequestId); } else { @@ -578,10 +580,10 @@ export class SessionMessageHandler extends BaseMessageHandler { try { console.log('[SessionMessageHandler] Creating new Qwen session...'); - // Ensure connection (login) before creating a new session + // Ensure connection (auth) before creating a new session if (!this.agentManager.isConnected) { - const proceeded = await this.promptLogin( - 'You need to login before creating a new session.', + const proceeded = await this.promptAuth( + 'You need to configure your provider before creating a new session.', ); if (!proceeded) { return; @@ -610,16 +612,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to create a new session.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to create a new session.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ @@ -637,10 +639,10 @@ export class SessionMessageHandler extends BaseMessageHandler { try { console.log('[SessionMessageHandler] Switching to session:', sessionId); - // If not connected yet, offer to login or view offline + // If not connected yet, offer to authenticate or view offline if (!this.agentManager.isConnected) { - const choice = await this.promptLoginOrOffline( - 'You are not logged in. Login now to fully restore this session, or view it offline.', + const choice = await this.promptAuthOrOffline( + 'You are not authenticated. Configure your provider to fully restore this session, or view it offline.', ); if (choice === 'offline') { @@ -653,10 +655,10 @@ export class SessionMessageHandler extends BaseMessageHandler { data: { sessionId, messages }, }); vscode.window.showInformationMessage( - 'Showing cached session content. Login to interact with the AI.', + 'Showing cached session content. Configure your provider to interact with the AI.', ); return; - } else if (choice !== 'login') { + } else if (choice !== 'auth') { // User dismissed; do nothing return; } @@ -711,16 +713,16 @@ export class SessionMessageHandler extends BaseMessageHandler { ); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(loadError)) { + if (this.shouldPromptAuth(loadError)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to switch sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to switch sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); return; } @@ -765,16 +767,18 @@ export class SessionMessageHandler extends BaseMessageHandler { ); // Check for authentication/session expiration errors in session creation - if (this.shouldPromptLogin(createError)) { + if (this.shouldPromptAuth(createError)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to switch sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to switch sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { + message: 'Session expired. Please authenticate again.', + }, }); return; } @@ -789,7 +793,7 @@ export class SessionMessageHandler extends BaseMessageHandler { data: { sessionId, messages, session: sessionDetails }, }); vscode.window.showWarningMessage( - 'Showing cached session content. Login to interact with the AI.', + 'Showing cached session content. Configure your provider to interact with the AI.', ); } } @@ -799,16 +803,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to switch sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to switch sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ @@ -848,16 +852,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to view sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to view sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ @@ -895,10 +899,10 @@ export class SessionMessageHandler extends BaseMessageHandler { */ private async handleResumeSession(sessionId: string): Promise { try { - // If not connected, offer to login or view offline + // If not connected, offer to authenticate or view offline if (!this.agentManager.isConnected) { - const choice = await this.promptLoginOrOffline( - 'You are not logged in. Login now to fully restore this session, or view it offline.', + const choice = await this.promptAuthOrOffline( + 'You are not authenticated. Configure your provider to fully restore this session, or view it offline.', ); if (choice === 'offline') { @@ -910,10 +914,10 @@ export class SessionMessageHandler extends BaseMessageHandler { data: { sessionId, messages }, }); vscode.window.showInformationMessage( - 'Showing cached session content. Login to interact with the AI.', + 'Showing cached session content. Configure your provider to interact with the AI.', ); return; - } else if (choice !== 'login') { + } else if (choice !== 'auth') { return; } } @@ -937,16 +941,16 @@ export class SessionMessageHandler extends BaseMessageHandler { return; } catch (acpError) { // Check for authentication/session expiration errors - if (this.shouldPromptLogin(acpError)) { + if (this.shouldPromptAuth(acpError)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to resume sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to resume sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); return; } @@ -959,16 +963,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to resume sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to resume sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts index 2344f7caa..517d2db4e 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -110,12 +110,12 @@ export const useMessageSubmit = ({ inputFieldRef.current.setAttribute('data-empty', 'true'); } vscode.postMessage({ - type: 'login', + type: 'auth', data: {}, }); - // Show a friendly loading message in the chat while logging in + // Show a friendly loading message in the chat while authenticating try { - messageHandling.setWaitingForResponse('Logging in to Qwen Code...'); + messageHandling.setWaitingForResponse('Authenticating Qwen Code...'); } catch (_err) { // Best-effort UI hint; ignore if hook not available } diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 0eca0d8eb..af65c39df 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -456,15 +456,8 @@ export const useWebViewMessages = ({ break; } - case 'loginSuccess': { - // Clear loading state and show a short assistant notice + case 'authSuccess': { handlers.messageHandling.clearWaitingForResponse(); - handlers.messageHandling.addMessage({ - role: 'assistant', - content: 'Successfully logged in. You can continue chatting.', - timestamp: Date.now(), - }); - // Set authentication state to true handlers.setIsAuthenticated?.(true); break; } @@ -494,12 +487,12 @@ export const useWebViewMessages = ({ break; } - case 'loginError': { + case 'authError': { // Clear loading state and show error notice handlers.messageHandling.clearWaitingForResponse(); const errorMsg = (message?.data?.message as string) || - 'Login failed. Please try again.'; + 'Auth failed. Please try again.'; handlers.messageHandling.addMessage({ role: 'assistant', content: errorMsg, diff --git a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index d400fa727..6c5460cfc 100644 --- a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -75,10 +75,19 @@ export class MessageHandler { } /** - * Set login handler + * Set auth interactive handler — interactive auth flow. */ - setLoginHandler(handler: () => Promise): void { - this.router.setLoginHandler(handler); + setAuthInteractiveHandler( + handler: ( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise, + ): void { + this.router.setAuthInteractiveHandler(handler); } /** diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index b9d52e4ad..4412cdb6e 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -7,23 +7,36 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { + mockConfigChangeHandlers, availableCommandsCallbackRef, mockCreateImagePathResolver, + mockConfigGet, + mockConfigUpdate, mockGetGlobalTempDir, mockGetPanel, mockMessageHandlerInstances, + mockOnDidChangeConfiguration, mockOnDidChangeActiveTextEditor, mockOnDidChangeTextEditorSelection, mockOpenExternal, + mockReadQwenSettingsForVSCode, + mockWriteCodingPlanConfig, + mockWriteModelProvidersConfig, + mockClearPersistedAuth, slashCommandNotificationCallbackRef, mockQwenAgentManagerInstances, } = vi.hoisted(() => ({ + mockConfigChangeHandlers: [] as Array< + (event: { affectsConfiguration: (section: string) => boolean }) => unknown + >, availableCommandsCallbackRef: { current: undefined as | ((commands: Array<{ name: string; description?: string }>) => void) | undefined, }, mockCreateImagePathResolver: vi.fn(), + mockConfigGet: vi.fn(), + mockConfigUpdate: vi.fn(), mockGetGlobalTempDir: vi.fn(() => '/global-temp'), mockGetPanel: vi.fn<() => { webview: { postMessage: unknown } } | null>( () => null, @@ -34,9 +47,29 @@ const { data: { optionId?: string }; }) => void; }>, + mockOnDidChangeConfiguration: vi.fn( + ( + handler: (event: { + affectsConfiguration: (section: string) => boolean; + }) => unknown, + ) => { + mockConfigChangeHandlers.push(handler); + return { dispose: vi.fn() }; + }, + ), mockOnDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), mockOnDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), mockOpenExternal: vi.fn(), + mockReadQwenSettingsForVSCode: vi.fn< + () => { + provider: 'coding-plan' | 'api-key'; + apiKey: string; + codingPlanRegion: 'china' | 'global'; + } | null + >(() => null), + mockWriteCodingPlanConfig: vi.fn(() => ({})), + mockWriteModelProvidersConfig: vi.fn(), + mockClearPersistedAuth: vi.fn(), slashCommandNotificationCallbackRef: { current: undefined as | ((event: { @@ -50,6 +83,7 @@ const { mockQwenAgentManagerInstances: [] as Array<{ permissionRequestCallback?: (request: unknown) => Promise; cancelCurrentPrompt: ReturnType; + disconnect: ReturnType; }>, })); @@ -66,6 +100,9 @@ vi.mock('@qwen-code/qwen-code-core', async () => { }); vi.mock('vscode', () => ({ + ConfigurationTarget: { + Global: 'global', + }, Uri: { joinPath: vi.fn((base: { fsPath?: string }, ...parts: string[]) => ({ fsPath: `${base.fsPath ?? ''}/${parts.join('/')}`.replace(/\/+/g, '/'), @@ -82,12 +119,24 @@ vi.mock('vscode', () => ({ }, workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace-root' } }], + onDidChangeConfiguration: mockOnDidChangeConfiguration, + getConfiguration: vi.fn(() => ({ + get: mockConfigGet, + update: mockConfigUpdate, + })), }, commands: { executeCommand: vi.fn(), }, })); +vi.mock('../../services/settingsWriter.js', () => ({ + writeCodingPlanConfig: mockWriteCodingPlanConfig, + writeModelProvidersConfig: mockWriteModelProvidersConfig, + readQwenSettingsForVSCode: mockReadQwenSettingsForVSCode, + clearPersistedAuth: mockClearPersistedAuth, +})); + vi.mock('../../services/qwenAgentManager.js', () => ({ QwenAgentManager: class { isConnected = false; @@ -179,7 +228,7 @@ vi.mock('./MessageHandler.js', () => ({ ) { mockMessageHandlerInstances.push(this); } - setLoginHandler = vi.fn(); + setAuthInteractiveHandler = vi.fn(); permissionHandler?: (message: { type: string; data: { optionId?: string }; @@ -227,6 +276,10 @@ import { MAX_PANEL_TITLE_LENGTH, } from '../utils/panelTitleUtils.js'; +const createConfigChangeEvent = (...affectedSections: string[]) => ({ + affectsConfiguration: (section: string) => affectedSections.includes(section), +}); + type WebViewMessageHandler = (message: { type: string; data?: unknown; @@ -278,12 +331,19 @@ async function setupAttachedProvider(options?: { return { webview, postMessage, provider, messageHandler }; } +beforeEach(() => { + mockConfigChangeHandlers.length = 0; +}); + describe('WebViewProvider.attachToView', () => { beforeEach(() => { vi.clearAllMocks(); mockMessageHandlerInstances.length = 0; mockQwenAgentManagerInstances.length = 0; mockGetPanel.mockReturnValue(null); + mockConfigGet.mockImplementation( + (_key: string, defaultValue: unknown) => defaultValue, + ); availableCommandsCallbackRef.current = undefined; slashCommandNotificationCallbackRef.current = undefined; mockCreateImagePathResolver.mockReturnValue((paths: string[]) => @@ -666,6 +726,231 @@ describe('WebViewProvider.attachToView', () => { }); }); +describe('WebViewProvider settings sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfigChangeHandlers.length = 0; + mockConfigGet.mockImplementation( + (_key: string, defaultValue: unknown) => defaultValue, + ); + }); + + it('does not report success for api-key settings without interactive auth data', async () => { + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'apiKey') { + return 'sk-test'; + } + if (key === 'provider') { + return 'api-key'; + } + return defaultValue; + }); + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + const synced = await ( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise; + } + ).syncVSCodeSettingsToQwenConfig(); + + expect(synced).toBe(false); + expect(mockWriteCodingPlanConfig).not.toHaveBeenCalled(); + expect(mockWriteModelProvidersConfig).not.toHaveBeenCalled(); + }); + + it('only syncs non-secret VS Code settings from ~/.qwen/settings.json', async () => { + mockReadQwenSettingsForVSCode.mockReturnValue({ + provider: 'coding-plan', + apiKey: 'sk-updated', + codingPlanRegion: 'global', + }); + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'provider') { + return 'api-key'; + } + if (key === 'apiKey') { + return 'sk-current'; + } + if (key === 'codingPlanRegion') { + return 'china'; + } + return defaultValue; + }); + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + await ( + provider as unknown as { + syncQwenConfigToVSCodeSettings: () => Promise; + } + ).syncQwenConfigToVSCodeSettings(); + + expect(mockConfigUpdate).toHaveBeenCalledTimes(2); + expect(mockConfigUpdate).toHaveBeenCalledWith( + 'provider', + 'coding-plan', + expect.anything(), + ); + expect(mockConfigUpdate).toHaveBeenCalledWith( + 'codingPlanRegion', + 'global', + expect.anything(), + ); + expect(mockConfigUpdate).not.toHaveBeenCalledWith( + 'apiKey', + 'sk-updated', + expect.anything(), + ); + }); + + it('ignores non-auth qwen-code setting changes', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + const syncSpy = vi + .spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise; + }, + 'syncVSCodeSettingsToQwenConfig', + ) + .mockResolvedValue(true); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + await configChangeHandler?.(createConfigChangeEvent('qwen-code')); + + expect(syncSpy).not.toHaveBeenCalled(); + }); + + it('reacts to auth-related qwen-code setting changes', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + const syncSpy = vi + .spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise; + }, + 'syncVSCodeSettingsToQwenConfig', + ) + .mockResolvedValue(false); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + await configChangeHandler?.( + createConfigChangeEvent('qwen-code', 'qwen-code.apiKey'), + ); + + expect(syncSpy).toHaveBeenCalledTimes(1); + }); + + it('clears persisted credentials and disconnects when apiKey is emptied', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + // Simulate an already-initialized agent connection + (provider as unknown as { agentInitialized: boolean }).agentInitialized = + true; + + // syncVSCodeSettingsToQwenConfig returns false because apiKey is empty + vi.spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise; + }, + 'syncVSCodeSettingsToQwenConfig', + ).mockResolvedValue(false); + + // apiKey is empty (user cleared it in Settings) + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'apiKey') { + return ''; + } + return defaultValue; + }); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + await configChangeHandler?.( + createConfigChangeEvent('qwen-code', 'qwen-code.apiKey'), + ); + + // Should clear persisted auth + expect(mockClearPersistedAuth).toHaveBeenCalledTimes(1); + + // Should disconnect the agent + const agentManager = mockQwenAgentManagerInstances.at(-1); + expect(agentManager?.disconnect).toHaveBeenCalledTimes(1); + + // agentInitialized should be reset + expect( + (provider as unknown as { agentInitialized: boolean }).agentInitialized, + ).toBe(false); + }); + + it('does not de-auth when non-apiKey auth settings change on an api-key provider', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + // Simulate an already-initialized agent with api-key provider + (provider as unknown as { agentInitialized: boolean }).agentInitialized = + true; + + // syncVSCodeSettingsToQwenConfig returns false — normal for api-key providers + vi.spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise; + }, + 'syncVSCodeSettingsToQwenConfig', + ).mockResolvedValue(false); + + // apiKey is empty because api-key providers don't use this VS Code setting + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'apiKey') { + return ''; + } + if (key === 'provider') { + return 'api-key'; + } + return defaultValue; + }); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + // Changing codingPlanRegion should NOT trigger de-auth + await configChangeHandler?.( + createConfigChangeEvent('qwen-code', 'qwen-code.codingPlanRegion'), + ); + + expect(mockClearPersistedAuth).not.toHaveBeenCalled(); + + const agentManager = mockQwenAgentManagerInstances.at(-1); + expect(agentManager?.disconnect).not.toHaveBeenCalled(); + + // agentInitialized should remain true + expect( + (provider as unknown as { agentInitialized: boolean }).agentInitialized, + ).toBe(true); + }); +}); + describe('WebViewProvider.createNewSession', () => { it('forces a fresh ACP session for the sidebar new-session action', async () => { const provider = new WebViewProvider( diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 2a2c20071..bcae4dd88 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -26,8 +26,20 @@ import { createImagePathResolver } from '../utils/imageHandler.js'; import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; +import { + writeCodingPlanConfig, + writeModelProvidersConfig, + readQwenSettingsForVSCode, + clearPersistedAuth, +} from '../../services/settingsWriter.js'; import { parseInsightMessage } from '@qwen-code/qwen-code-core'; +const AUTH_RELATED_QWEN_SETTINGS = [ + 'qwen-code.provider', + 'qwen-code.apiKey', + 'qwen-code.codingPlanRegion', +] as const; + function isInsightCommand(command: string): boolean { const [firstToken = ''] = command.trim().split(/\s+/, 1); return firstToken.replace(/^\/+/, '') === 'insight'; @@ -40,6 +52,7 @@ export class WebViewProvider { private conversationStore: ConversationStore; private disposables: vscode.Disposable[] = []; private agentInitialized = false; // Track if agent has been initialized + private isSyncingToVSCode = false; // Guard to prevent config change loop // Track a pending permission request and its resolver so extension commands // can "simulate" user choice from the command palette (e.g. after accepting // a diff, auto-allow read/execute, or auto-reject on cancel). @@ -70,6 +83,10 @@ export class WebViewProvider { /** Guards against concurrent auth-restore / connection init */ private initializationPromise: Promise | null = null; private isReconnecting = false; + /** Timer for the deferred auto-auth launch inside doInitializeAgentConnection */ + private autoAuthTimer: ReturnType | null = null; + /** Whether an explicit interactive auth flow is currently active */ + private authFlowActive = false; constructor( private context: vscode.ExtensionContext, @@ -100,10 +117,78 @@ export class WebViewProvider { (message) => this.sendMessageToWebView(message), ); - // Set login handler for /login command - direct force re-login - this.messageHandler.setLoginHandler(async () => { - await this.forceReLogin(); - }); + // Set auth interactive handler — interactive auth flow (QuickPick → InputBox → write settings → reconnect) + this.messageHandler.setAuthInteractiveHandler( + async (provider, region, apiKey, baseUrl, model, modelIds) => { + await this.handleAuthInteractive( + provider, + region, + apiKey, + baseUrl, + model, + modelIds, + ); + }, + ); + + // Watch for auth-related VSCode settings changes — auto-sync and reconnect. + // The isSyncingToVSCode guard prevents a loop when we programmatically populate VSCode settings. + const configChangeDisposable = vscode.workspace.onDidChangeConfiguration( + async (e) => { + const authSettingsChanged = AUTH_RELATED_QWEN_SETTINGS.some((setting) => + e.affectsConfiguration(setting), + ); + + if (authSettingsChanged && !this.isSyncingToVSCode) { + console.log( + '[WebViewProvider] Auth-related qwen-code settings changed by user, syncing...', + ); + const synced = await this.syncVSCodeSettingsToQwenConfig(); + if (synced && this.agentInitialized) { + // Settings changed and we have an active connection — reconnect + try { + this.agentManager.disconnect(); + this.agentInitialized = false; + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.doInitializeAgentConnection({ + autoAuthenticate: false, + }); + } catch (e) { + console.error( + '[WebViewProvider] Reconnect after settings change failed:', + e, + ); + } + } else if ( + !synced && + this.agentInitialized && + e.affectsConfiguration('qwen-code.apiKey') + ) { + // Only de-auth when qwen-code.apiKey itself was cleared. + // Other auth-related settings (provider, codingPlanRegion) returning + // synced=false is normal for api-key providers — those are managed by + // the interactive auth flow, not VS Code Settings sync. + const apiKey = vscode.workspace + .getConfiguration('qwen-code') + .get('apiKey', ''); + if (!apiKey) { + console.log( + '[WebViewProvider] apiKey cleared — de-authenticating and clearing persisted credentials', + ); + clearPersistedAuth(); + this.agentManager.disconnect(); + this.agentInitialized = false; + this.authState = false; + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } + } + } + }, + ); + this.disposables.push(configChangeDisposable); // Setup file watchers for cache invalidation const fileWatcherDisposable = this.messageHandler.setupFileWatchers(); @@ -866,6 +951,29 @@ export class WebViewProvider { await this.attemptAuthStateRestoration(); } + /** + * Launch the interactive auth flow (QuickPick → InputBox → write settings → reconnect). + * Guards against concurrent launches: if auto-auth was scheduled by + * doInitializeAgentConnection's deferred timeout, it is cancelled first. + */ + async startInteractiveAuth(): Promise { + // Cancel any pending auto-auth from doInitializeAgentConnection so we + // don't end up with two overlapping auth flows. + if (this.autoAuthTimer) { + clearTimeout(this.autoAuthTimer); + this.autoAuthTimer = null; + } + if (this.authFlowActive) { + return; + } + this.authFlowActive = true; + try { + await this.messageHandler.route({ type: 'auth' }); + } finally { + this.authFlowActive = false; + } + } + setInitialModelId(modelId: string | null | undefined): void { this.initialModelId = typeof modelId === 'string' && modelId.trim().length > 0 @@ -874,8 +982,113 @@ export class WebViewProvider { } /** - * Attempt to restore authentication state and initialize connection - * This is called when the webview is first shown + * Sync VSCode extension settings (qwen-code.*) to ~/.qwen/settings.json + * if an API key is configured. This enables auto-connect on startup + * without requiring the user to click "Connect" each time. + * + * @returns true if settings were synced (apiKey is configured), false otherwise + */ + private async syncVSCodeSettingsToQwenConfig(): Promise { + const config = vscode.workspace.getConfiguration('qwen-code'); + const apiKey = config.get('apiKey', ''); + + if (!apiKey) { + return false; + } + + try { + const provider = config.get('provider', 'coding-plan'); + + if (provider !== 'coding-plan') { + console.log( + '[WebViewProvider] Skipping VSCode settings sync for api-key provider; interactive auth owns provider details', + ); + return false; + } + + const region = config.get<'china' | 'global'>( + 'codingPlanRegion', + 'china', + ); + writeCodingPlanConfig(region, apiKey); + + console.log( + `[WebViewProvider] Synced VSCode settings → ~/.qwen/settings.json (provider=${provider})`, + ); + return true; + } catch (error) { + console.error('[WebViewProvider] Failed to sync VSCode settings:', error); + return false; + } + } + + /** + * Sync ~/.qwen/settings.json values back to VSCode Settings UI. + * This makes existing CLI-configured non-secret metadata visible in the + * VSCode Settings page without mirroring credentials into settings.json. + */ + private async syncQwenConfigToVSCodeSettings(): Promise { + try { + const qwenSettings = readQwenSettingsForVSCode(); + if (!qwenSettings) { + return; + } + + console.log( + '[WebViewProvider] Syncing ~/.qwen/settings.json → VSCode settings', + ); + + // Set guard to prevent onDidChangeConfiguration from triggering a write-back + const config = vscode.workspace.getConfiguration('qwen-code'); + const target = vscode.ConfigurationTarget.Global; + const updates: Array> = []; + + if ( + config.get('provider', 'coding-plan') !== qwenSettings.provider + ) { + updates.push(config.update('provider', qwenSettings.provider, target)); + } + if ( + config.get<'china' | 'global'>('codingPlanRegion', 'china') !== + qwenSettings.codingPlanRegion + ) { + updates.push( + config.update( + 'codingPlanRegion', + qwenSettings.codingPlanRegion, + target, + ), + ); + } + + if (updates.length === 0) { + console.log( + '[WebViewProvider] VSCode settings already match ~/.qwen/settings.json', + ); + return; + } + + this.isSyncingToVSCode = true; + + try { + await Promise.all(updates); + } finally { + this.isSyncingToVSCode = false; + } + } catch (error) { + console.error( + '[WebViewProvider] Failed to sync qwen config to VSCode settings:', + error, + ); + } + } + + /** + * Attempt to restore authentication state and initialize connection. + * On startup, sync ~/.qwen/settings.json → VSCode settings so the Settings UI + * reflects existing non-secret CLI config, then attempt a connection. + * Writing back to ~/.qwen/settings.json happens through the auth flow and + * auth-related VSCode setting changes. */ private async attemptAuthStateRestoration(): Promise { // Prevent concurrent initialization attempts (e.g. visibility toggle + webviewReady race) @@ -885,6 +1098,8 @@ export class WebViewProvider { this.initializationPromise = (async () => { try { + await this.syncQwenConfigToVSCodeSettings(); + console.log('[WebViewProvider] Attempting connection...'); // Attempt a connection to detect prior auth without forcing login await this.initializeAgentConnection({ autoAuthenticate: false }); @@ -954,7 +1169,7 @@ export class WebViewProvider { // send authState message and return without creating session if (connectResult.requiresAuth && !autoAuthenticate) { console.log( - '[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning', + '[WebViewProvider] Authentication required, launching auth flow...', ); this.sendMessageToWebView({ type: 'authState', @@ -962,6 +1177,22 @@ export class WebViewProvider { }); // Initialize empty conversation to allow browsing history await this.initializeEmptyConversation(); + + // Auto-launch the interactive auth flow (QuickPick → InputBox) + // so the user is immediately guided to configure their provider, + // mirroring CLI's behavior of showing AuthDialog on first run. + // Deferred to avoid conflicting with the current connection init. + // The timer is stored so startInteractiveAuth() can cancel it + // to prevent two overlapping auth flows. + this.autoAuthTimer = setTimeout(() => { + this.autoAuthTimer = null; + if (!this.authFlowActive) { + this.authFlowActive = true; + void this.messageHandler.route({ type: 'auth' }).finally(() => { + this.authFlowActive = false; + }); + } + }, 100); return; } @@ -1009,70 +1240,100 @@ export class WebViewProvider { } /** - * Force re-login by clearing auth cache and reconnecting - * Called when user explicitly uses /login command + * Handle auth interactive — interactive auth flow result. + * Writes provider config to ~/.qwen/settings.json and reconnects. + * Mirrors the CLI's `qwen auth coding-plan` / `qwen auth` flow. */ - async forceReLogin(): Promise { - console.log('[WebViewProvider] Force re-login requested'); + private async handleAuthInteractive( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ): Promise { + if (!apiKey) { + this.sendMessageToWebView({ + type: 'authError', + data: { message: 'API key is required.' }, + }); + return; + } - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - cancellable: false, - }, - async (progress) => { - try { - progress.report({ message: 'Preparing sign-in...' }); - - // Disconnect existing connection if any - if (this.agentInitialized) { - try { - this.agentManager.disconnect(); - console.log('[WebViewProvider] Existing connection disconnected'); - } catch (_error) { - console.log('[WebViewProvider] Error disconnecting:', _error); - } - this.agentInitialized = false; - } - - // Wait a moment for cleanup to complete - await new Promise((resolve) => setTimeout(resolve, 300)); - - progress.report({ - message: 'Connecting to CLI and starting sign-in...', - }); - - // Reinitialize connection (will trigger fresh authentication) - await this.doInitializeAgentConnection({ autoAuthenticate: true }); - console.log( - '[WebViewProvider] Force re-login completed successfully', - ); - - // Send success notification to WebView - this.sendMessageToWebView({ - type: 'loginSuccess', - data: { message: 'Successfully logged in!' }, - }); - } catch (_error) { - const errorMsg = getErrorMessage(_error); - console.error('[WebViewProvider] Force re-login failed:', _error); - console.error( - '[WebViewProvider] Error stack:', - _error instanceof Error ? _error.stack : 'N/A', - ); - - // Send error notification to WebView - this.sendMessageToWebView({ - type: 'loginError', - data: { - message: `Login failed: ${errorMsg}`, - }, - }); - - throw _error; - } - }, + console.log( + `[WebViewProvider] authInteractive: provider=${provider}, region=${region}, model=${model}`, ); + + try { + if (provider === 'coding-plan') { + writeCodingPlanConfig(region === 'global' ? 'global' : 'china', apiKey); + } else if (provider === 'alibaba-standard') { + // Alibaba Standard — multiple models sharing the same base URL + const modelBaseUrl = + baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + const ids = (modelIds || model || 'qwen3.5-plus') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const providers: Record = {}; + for (const id of ids) { + providers[id] = modelBaseUrl; + } + writeModelProvidersConfig({ + apiKey, + modelProviders: providers, + activeModel: ids[0] || 'qwen3.5-plus', + }); + } else { + // Custom API Key — single model entry + const modelId = model || 'default'; + const modelBaseUrl = baseUrl || 'https://api.openai.com/v1'; + writeModelProvidersConfig({ + apiKey, + modelProviders: { [modelId]: modelBaseUrl }, + activeModel: modelId, + }); + } + + // Disconnect + reconnect + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + } catch (e) { + console.log('[WebViewProvider] Error disconnecting:', e); + } + this.agentInitialized = false; + } + + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.doInitializeAgentConnection({ autoAuthenticate: false }); + + // Only emit authSuccess when the reconnection actually authenticated. + // doInitializeAgentConnection updates this.authState via sendMessageToWebView; + // if credentials were rejected, authState will be false and we should not + // claim success (which would briefly show a success toast then re-open auth). + if (this.authState === true) { + this.sendMessageToWebView({ + type: 'authSuccess', + data: { message: 'Provider configured successfully!' }, + }); + } else { + this.sendMessageToWebView({ + type: 'authError', + data: { + message: + 'Connection established but authentication failed. Please check your credentials.', + }, + }); + } + } catch (error) { + const errorMsg = getErrorMessage(error); + console.error('[WebViewProvider] authInteractive failed:', error); + this.sendMessageToWebView({ + type: 'authError', + data: { message: `Configuration failed: ${errorMsg}` }, + }); + } } /** @@ -1324,11 +1585,11 @@ export class WebViewProvider { } break; case 'agentConnected': - case 'loginSuccess': + case 'authSuccess': this.authState = true; break; case 'agentConnectionError': - case 'loginError': + case 'authError': this.authState = false; break; default: diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx index d2eee02d6..02163fa5f 100644 --- a/packages/webui/src/components/PermissionDrawer.tsx +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -187,14 +187,17 @@ export const PermissionDrawer: FC = ({ return null; } for (const item of toolCall.content) { + const itemType = item['type']; + const itemContent = item['content']; + if ( - item.type === 'content' && - typeof item.content === 'object' && - item.content !== null + itemType === 'content' && + typeof itemContent === 'object' && + itemContent !== null ) { - const inner = item.content as { type?: string; text?: string }; - if (inner.type === 'text' && typeof inner.text === 'string') { - return inner.text; + const inner = itemContent as Record; + if (inner['type'] === 'text' && typeof inner['text'] === 'string') { + return inner['text']; } } }