feat(vscode): replace OAuth with Coding Plan / API Key provider setup (#3398)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* refactor(core): move codingPlan constants from cli to core package

Extract Coding Plan region configs, model templates, and utility
functions into packages/core/src/constants/ so both CLI and VSCode
extension can import from a shared source of truth.

* refactor(cli): import codingPlan constants from core instead of local path

Update all CLI files to import CodingPlanRegion, CODING_PLAN_ENV_KEY,
and related utilities from @qwen-code/qwen-code-core, replacing the
local ../../constants/codingPlan.js imports.

* feat(vscode-ide-companion): replace login flow with provider setup via VSCode Settings

Replace the OAuth-based login command with a settings-driven provider
configuration flow. Users now configure Coding Plan or API Key providers
through VSCode Settings (qwen-code.*), which auto-syncs to
~/.qwen/settings.json.

- Rename login command to auth, opening VSCode Settings panel
- Add /auth2 interactive flow (QuickPick + InputBox)
- Add ProviderSetupForm onboarding component with inline config
- Add bidirectional sync between VSCode settings and ~/.qwen/settings.json
- Add settingsWriter service for direct settings.json read/write
- Add VSCode configuration schema (provider, apiKey, region, model, etc.)
- Update all login/session messages to use auth terminology

* refactor(vscode-ide-companion): rename auth2→auth, remove dead code, fix sync guard

- Rename auth2 to auth for all message types, handlers, and slash command
- Remove unused InfoBanner.tsx (128 lines, no references)
- Remove dead openProviderSettings handler (no callers)
- Remove redundant qwen-code.baseUrl VSCode setting (already in modelProviders)
- Replace unreliable setTimeout(500) sync guard with await Promise.all + finally
- Clean up old authHandler/setAuthHandler in favor of authInteractiveHandler

* refactor(vscode-ide-companion): remove dead VSCode Settings plumbing, simplify sync

- Remove qwen-code.modelProviders and qwen-code.model from package.json
  (model switching handled by chat UI's /model command, not VSCode Settings)
- Remove connectWithSettings message handler and plumbing
  (no webview component sends this message type)
- Remove handleConnectWithSettings method from WebViewProvider
- Simplify syncVSCodeSettingsToQwenConfig: only sync provider/apiKey/region
- Simplify syncQwenConfigToVSCodeSettings: only populate provider/apiKey/region
- Simplify QwenSettingsForVSCode interface: remove modelProviders and model
- Improve Onboarding UI: logo above card, better hierarchy, arrow icon on button

* fix(vscode-ide-companion): add missing vscode.workspace mock in test

Add onDidChangeConfiguration and getConfiguration to the vscode.workspace
mock in WebViewProvider.test.ts to fix CI test failures.

* fix(vscode-ide-companion): clean up stale coding plan state, add auth cancel handling, add tests

- Clear CODING_PLAN_ENV_KEY and codingPlan metadata when switching to api-key mode
- Add authCancelled notification when QuickPick/InputBox is dismissed
- ProviderSetupForm resets button state on authCancelled
- syncVSCodeSettingsToQwenConfig returns false for api-key mode (no-op)
- Fix Onboarding vertical centering (flex-1 min-h-0)
- Import from @qwen-code/qwen-code-core top-level instead of deep paths
- Add tests: settingsWriter, ProviderSetupForm cancel, AuthMessageHandler cancel, WebViewProvider sync
- Fix redundant ternary in pick() helper

* fix(vscode-ide-companion): force center Onboarding against parent override

Parent container uses [&>*]:items-start and [&>*]:text-left which overrides
Tailwind classes. Use inline style for alignItems/justifyContent/textAlign
to ensure Onboarding is always centered both horizontally and vertically.

* fix(vscode-ide-companion): bundle onboarding logo

* test(vscode-ide-companion): add png loader to bundle test

* fix(vscode-ide-companion/webview): avoid redundant auth sync reconnects

* fix(vscode-ide-companion/webview): fix auth sync typecheck

* docs(vscode-ide-companion): clarify auth restoration flow

* fix(webui): use bracket access for permission drawer plan content

* fix(vscode-ide-companion): guard authSuccess emission on actual auth state

After reconnecting in handleAuthInteractive, doInitializeAgentConnection
may return without throwing even when credentials are rejected (it sends
authState:false internally and returns early). Previously we unconditionally
emitted authSuccess, which contradicted the failed auth state and could
briefly show a success toast before re-opening the auth flow.

Now we check this.authState after reconnection: only emit authSuccess when
authentication actually succeeded, otherwise emit authError with a clear
credentials message.

Addresses review feedback from PR #3398.

* fix(vscode): address auth setup review feedback

* fix(vscode-ide-companion): guard concurrent auth flows, merge model providers

- Add authFlowActive mutex and autoAuthTimer to WebViewProvider so
  startInteractiveAuth() cancels the deferred auto-auth timeout,
  preventing two overlapping QuickPick flows from a single command.
- Change writeModelProvidersConfig() to merge new entries with existing
  non-target models (different envKey) instead of replacing the entire
  array, preserving unrelated providers like Coding Plan.

* fix(vscode-ide-companion): handle apiKey clearing as de-auth signal, fix auto-auth race, clean imports

- Add clearPersistedAuth() to settingsWriter.ts: removes selectedType,
  API keys, and coding plan metadata from ~/.qwen/settings.json
- Config change handler now detects empty apiKey with active agent and
  triggers de-auth: clear credentials, disconnect, update authState
- Auto-auth timer callback now properly sets authFlowActive mutex to
  prevent concurrent auth flows with startInteractiveAuth()
- Add test covering the de-auth path (clearPersistedAuth + disconnect)
- Fix import formatting in 7 CLI files (spacing, trailing commas)
- Remove duplicate comment in attemptAuthStateRestoration()

* fix(vscode-ide-companion): scope de-auth to apiKey changes only

The previous de-auth logic triggered on any auth-related setting change
where syncVSCodeSettingsToQwenConfig() returned false. For api-key
providers this is the normal path (interactive auth owns config), so
changing codingPlanRegion or provider would incorrectly wipe OPENAI_API_KEY.

Now the de-auth branch only fires when e.affectsConfiguration('qwen-code.apiKey')
is true AND the value is empty, preventing false-positive credential clearing.

Add regression test: non-apiKey setting changes on an api-key provider
must not trigger clearPersistedAuth or disconnect.

* fix(vscode-ide-companion): add disconnect to mock type to fix CI typecheck

The hoisted mockQwenAgentManagerInstances type was missing the
disconnect property, causing TS2339 in the de-auth test assertions.
This commit is contained in:
易良 2026-04-21 22:20:58 +08:00 committed by GitHub
parent ebe364d0b8
commit e49867a762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2284 additions and 282 deletions

View file

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

View file

@ -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', () => ({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"',
},

View file

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

View file

@ -0,0 +1,4 @@
declare module '*.png' {
const value: string;
export default value;
}

View file

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

View file

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

View file

@ -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<typeof import('@qwen-code/qwen-code-core')>();
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<string, unknown>;
const env = settings.env as Record<string, string>;
const modelProviders = settings.modelProviders as Record<string, unknown>;
const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array<
Record<string, string>
>;
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',
});
});
});

View file

@ -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<string, string>;
/**
* 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<string, unknown> {
try {
const content = fs.readFileSync(Storage.getGlobalSettingsPath(), 'utf-8');
return JSON.parse(content) as Record<string, unknown>;
} catch {
return {};
}
}
/**
* Write ~/.qwen/settings.json (creates dir if needed).
*/
function writeSettings(settings: Record<string, unknown>): 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<string, unknown>,
...keys: string[]
): Record<string, unknown> {
let current = obj;
for (const key of keys) {
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
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<string, unknown> | undefined,
): Array<Record<string, unknown>> {
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<Record<string, unknown>>;
}
}
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<string, unknown>,
);
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<string, unknown>,
);
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<string, unknown> | undefined;
const auth = security?.auth as Record<string, unknown> | undefined;
if (!auth?.selectedType) {
return null;
}
const env = (settings.env ?? {}) as Record<string, string>;
const codingPlan = settings.codingPlan as Record<string, unknown> | 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<string, unknown>
| 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<string, unknown> | undefined;
if (security?.auth) {
delete (security.auth as Record<string, unknown>).selectedType;
}
// Remove API keys
const env = settings.env as Record<string, unknown> | 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,
);
}
}

View file

@ -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).

View file

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

View file

@ -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 ? (
<Onboarding
onLogin={() => {
vscode.postMessage({ type: 'login', data: {} });
messageHandling.setWaitingForResponse(
'Logging in to Qwen Code...',
);
}}
/>
<Onboarding />
) : isAuthenticated === null ? (
<EmptyState loadingMessage="Checking login status…" />
<div className="flex flex-col items-center justify-center h-full gap-3">
<span
className="inline-block w-6 h-6 animate-spin rounded-full border-2"
style={{
borderColor: 'var(--app-secondary-foreground)',
borderTopColor: 'transparent',
}}
/>
<span
className="text-sm"
style={{ color: 'var(--app-secondary-foreground)' }}
>
Preparing Qwen Code...
</span>
</div>
) : (
<EmptyState isAuthenticated />
)

View file

@ -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: () => <button type="button">Get Started</button>,
}));
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(<Onboarding />);
});
const logo = container?.querySelector('img[alt="Qwen Code"]');
expect(logo).toBeTruthy();
expect(logo?.getAttribute('src')).toBeTruthy();
});
});

View file

@ -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<OnboardingPageProps> = ({ onLogin }) => {
const iconUri = generateIconUrl('icon.png');
export const Onboarding: FC = () => (
<div
className="flex flex-col flex-1 min-h-0 px-6"
style={{
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
}}
>
{/* Logo + title block — sits above the card for visual breathing room */}
<div className="flex flex-col items-center gap-3 mb-6">
<img
src={iconUrl}
alt="Qwen Code"
className="w-12 h-12 object-contain"
/>
<div className="text-center">
<h1
className="text-base font-semibold"
style={{ color: 'var(--app-primary-foreground)' }}
>
Qwen Code
</h1>
<p
className="text-xs mt-1"
style={{ color: 'var(--app-secondary-foreground)' }}
>
AI-powered coding assistant for your editor
</p>
</div>
</div>
return <BaseOnboarding iconUrl={iconUri} onGetStarted={onLogin} />;
};
{/* Setup card */}
<div
className="w-full max-w-[300px] rounded-lg border p-4"
style={{
backgroundColor: 'var(--app-input-secondary-background)',
borderColor: 'var(--app-input-border)',
}}
>
<p
className="text-xs mb-3 text-center"
style={{ color: 'var(--app-secondary-foreground)' }}
>
Connect a model provider to get started
</p>
<ProviderSetupForm />
</div>
{/* Subtle hint below the card */}
<p
className="text-[10px] mt-4 text-center max-w-[260px]"
style={{ color: 'var(--app-secondary-foreground)', opacity: 0.6 }}
>
Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and
OpenAI-compatible endpoints
</p>
</div>
);

View file

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

View file

@ -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 }) => (
<span
className="inline-block animate-spin rounded-full border-2 border-current"
style={{
width: size,
height: size,
borderTopColor: 'transparent',
}}
/>
);
/**
* 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<string | null>(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 (
<div className="flex flex-col gap-2.5">
<button
onClick={handleGetStarted}
disabled={isConnecting}
className="w-full py-2 rounded-md text-[13px] font-medium flex items-center justify-center gap-2 transition-all"
style={{
backgroundColor: isConnecting
? 'var(--app-input-secondary-background)'
: 'var(--app-primary, var(--app-button-background))',
color: isConnecting
? 'var(--app-secondary-foreground)'
: 'var(--app-button-foreground, #fff)',
cursor: isConnecting ? 'not-allowed' : 'pointer',
border: isConnecting
? '1px solid var(--app-input-border)'
: '1px solid transparent',
}}
>
{isConnecting ? (
<>
<Spinner />
Connecting...
</>
) : (
<>
Get Started
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.5 2.5L8 6L4.5 9.5" />
</svg>
</>
)}
</button>
{error && (
<div
className="text-[11px] leading-snug px-2.5 py-2 rounded"
style={{
backgroundColor: 'color-mix(in srgb, #ef4444 10%, transparent)',
color: '#f87171',
}}
>
{error}
</div>
)}
</div>
);
};

View file

@ -97,10 +97,10 @@ export const VSCodePlatformProvider: FC<VSCodePlatformProviderProps> = ({
});
}, [vscode]);
// Login handler
// Auth handler
const login = useCallback(() => {
vscode.postMessage({
type: 'login',
type: 'auth',
data: {},
});
}, [vscode]);

View file

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

View file

@ -13,22 +13,30 @@ import { getErrorMessage } from '../../utils/errorMessage.js';
* Handles all authentication-related messages
*/
export class AuthMessageHandler extends BaseMessageHandler {
private loginHandler: (() => Promise<void>) | null = null;
private authInteractiveHandler:
| ((
provider: string,
region?: string,
apiKey?: string,
baseUrl?: string,
model?: string,
modelIds?: string,
) => Promise<void>)
| null = null;
canHandle(messageType: string): boolean {
return ['login', 'getAccountInfo'].includes(messageType);
return ['auth', 'getAccountInfo'].includes(messageType);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
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>): void {
this.loginHandler = handler;
setAuthInteractiveHandler(
handler: (
provider: string,
region?: string,
apiKey?: string,
baseUrl?: string,
model?: string,
modelIds?: string,
) => Promise<void>,
): void {
this.authInteractiveHandler = handler;
}
/**
* Handle getAccountInfo request - queries ACP for live account info
* Handle getAccountInfo request
*/
private async handleGetAccountInfo(): Promise<void> {
try {
@ -71,45 +88,300 @@ export class AuthMessageHandler extends BaseMessageHandler {
}
}
/**
* Handle login request
*/
private async handleLogin(): Promise<void> {
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<string, string> = {
'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<T extends string>(
items: Array<{ label: string; description?: string; value: T }>,
title: string,
placeHolder: string,
): Promise<T | undefined> {
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<string | undefined> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
);
}
}
}

View file

@ -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>): 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>,
): void {
this.authHandler.setAuthInteractiveHandler(handler);
// SessionMessageHandler's authHandler is a simple () => Promise<void>.
// Wrap so "Configure" prompts trigger the full interactive auth QuickPick.
this.sessionHandler?.setAuthHandler?.(() =>
this.authHandler.handle({ type: 'auth' }),
);
}
/**

View file

@ -22,7 +22,7 @@ import { getErrorMessage } from '../../utils/errorMessage.js';
*/
export class SessionMessageHandler extends BaseMessageHandler {
private currentStreamContent = '';
private loginHandler: (() => Promise<void>) | null = null;
private authHandler: (() => Promise<void>) | 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>): void {
this.loginHandler = handler;
setAuthHandler(handler: () => Promise<void>): void {
this.authHandler = handler;
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
@ -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<boolean> {
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<boolean> {
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<void> {
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({

View file

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

View file

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

View file

@ -75,10 +75,19 @@ export class MessageHandler {
}
/**
* Set login handler
* Set auth interactive handler interactive auth flow.
*/
setLoginHandler(handler: () => Promise<void>): void {
this.router.setLoginHandler(handler);
setAuthInteractiveHandler(
handler: (
provider: string,
region?: string,
apiKey?: string,
baseUrl?: string,
model?: string,
modelIds?: string,
) => Promise<void>,
): void {
this.router.setAuthInteractiveHandler(handler);
}
/**

View file

@ -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<string>;
cancelCurrentPrompt: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
}>,
}));
@ -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<boolean>;
}
).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<void>;
}
).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<boolean>;
},
'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<boolean>;
},
'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<boolean>;
},
'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<boolean>;
},
'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(

View file

@ -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<void> | null = null;
private isReconnecting = false;
/** Timer for the deferred auto-auth launch inside doInitializeAgentConnection */
private autoAuthTimer: ReturnType<typeof setTimeout> | 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<string>('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<void> {
// 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<boolean> {
const config = vscode.workspace.getConfiguration('qwen-code');
const apiKey = config.get<string>('apiKey', '');
if (!apiKey) {
return false;
}
try {
const provider = config.get<string>('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<void> {
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<Thenable<void>> = [];
if (
config.get<string>('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<void> {
// 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<void> {
console.log('[WebViewProvider] Force re-login requested');
private async handleAuthInteractive(
provider: string,
region?: string,
apiKey?: string,
baseUrl?: string,
model?: string,
modelIds?: string,
): Promise<void> {
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<string, string> = {};
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:

View file

@ -187,14 +187,17 @@ export const PermissionDrawer: FC<PermissionDrawerProps> = ({
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<string, unknown>;
if (inner['type'] === 'text' && typeof inner['text'] === 'string') {
return inner['text'];
}
}
}