Compare commits

..

2 Commits

31 changed files with 1673 additions and 532 deletions

View File

@@ -1,207 +0,0 @@
name: 'Release VSCode IDE Companion'
on:
workflow_dispatch:
inputs:
version:
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
required: false
type: 'string'
ref:
description: 'The branch or ref (full git sha) to release from.'
required: true
type: 'string'
default: 'main'
dry_run:
description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.'
required: true
type: 'boolean'
default: true
create_preview_release:
description: 'Auto apply the preview release tag, input version is ignored.'
required: false
type: 'boolean'
default: false
force_skip_tests:
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
required: false
type: 'boolean'
default: false
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: false
jobs:
release-vscode-companion:
runs-on: 'ubuntu-latest'
environment:
name: 'production-release'
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ steps.version.outputs.RELEASE_TAG }}'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
contents: 'read'
issues: 'write'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Set booleans for simplified logic'
env:
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
id: 'vars'
run: |-
is_preview="false"
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
is_preview="true"
fi
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
is_dry_run="false"
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
is_dry_run="true"
fi
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci
- name: 'Install VSCE and OVSX'
run: |-
npm install -g @vscode/vsce
npm install -g ovsx
- name: 'Get the version'
id: 'version'
working-directory: 'packages/vscode-ide-companion'
run: |
# Get the base version from package.json regardless of scenario
BASE_VERSION=$(node -p "require('./package.json').version")
if [[ "${IS_PREVIEW}" == "true" ]]; then
# Generate preview version with timestamp based on actual package version
TIMESTAMP=$(date +%Y%m%d%H%M%S)
PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}"
RELEASE_TAG="preview.${TIMESTAMP}"
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT"
echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT"
else
# Use specified version or get from package.json
if [[ -n "${MANUAL_VERSION}" ]]; then
RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
else
RELEASE_VERSION="${BASE_VERSION}"
RELEASE_TAG="${BASE_VERSION}"
fi
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT"
fi
env:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Update package version (for preview releases)'
if: '${{ steps.vars.outputs.is_preview == ''true'' }}'
working-directory: 'packages/vscode-ide-companion'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Update package.json with preview version
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Run Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
working-directory: 'packages/vscode-ide-companion'
run: |
npm run test:ci
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
- name: 'Prepare VSCode Extension'
run: |
# Build and stage the extension + bundled CLI once.
npm --workspace=qwen-code-vscode-ide-companion run prepackage
- name: 'Package VSIX (dry run)'
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
working-directory: 'packages/vscode-ide-companion'
run: |-
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
else
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
fi
- name: 'Upload VSIX Artifact (dry run)'
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
uses: 'actions/upload-artifact@v4'
with:
name: 'qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
path: 'packages/qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
if-no-files-found: 'error'
- name: 'Publish to Microsoft Marketplace'
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
working-directory: 'packages/vscode-ide-companion'
env:
VSCE_PAT: '${{ secrets.VSCE_PAT }}'
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
run: |-
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
echo "Skipping Microsoft Marketplace for preview release"
else
vsce publish --pat "${VSCE_PAT}" --tag "${VSCODE_TAG}"
fi
- name: 'Publish to OpenVSX'
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
working-directory: 'packages/vscode-ide-companion'
env:
OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
run: |-
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
# For preview releases, publish with preview tag
# First package the extension for preview
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --pre-release
else
# Package and publish normally
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --tag "${VSCODE_TAG}"
fi
- name: 'Create Issue on Failure'
if: |-
${{ failure() }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
gh issue create \
--title "VSCode IDE Companion Release Failed for ${{ steps.version.outputs.RELEASE_VERSION }} on $(date +'%Y-%m-%d')" \
--body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"

View File

@@ -5,8 +5,6 @@
[![Node.js Version](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org/)
[![Downloads](https://img.shields.io/npm/dm/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code)
<a href="https://trendshift.io/repositories/15287" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15287" alt="QwenLM%2Fqwen-code | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**An open-source AI agent that lives in your terminal.**
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |

View File

@@ -139,7 +139,7 @@ export const addCommand: CommandModule = {
describe: 'Add a server',
builder: (yargs) =>
yargs
.usage('Usage: qwen mcp add [options] <name> <commandOrUrl> [args...]')
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
.parserConfiguration({
'unknown-options-as-args': true, // Pass unknown options as server args
'populate--': true, // Populate server args after -- separator

View File

@@ -28,7 +28,6 @@ type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { AnthropicContentConverter } from './converter.js';
import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js';
type StreamingBlockState = {
type: string;
@@ -55,9 +54,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
) {
const defaultHeaders = this.buildHeaders();
const baseURL = contentGeneratorConfig.baseUrl;
// Configure runtime options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let Anthropic SDK timeout control the request
const runtimeOptions = buildRuntimeFetchOptions('anthropic');
this.client = new Anthropic({
apiKey: contentGeneratorConfig.apiKey,
@@ -65,7 +61,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
timeout: contentGeneratorConfig.timeout,
maxRetries: contentGeneratorConfig.maxRetries,
defaultHeaders,
...runtimeOptions,
});
this.converter = new AnthropicContentConverter(

View File

@@ -19,8 +19,6 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { AuthType } from '../../contentGenerator.js';
import type { ChatCompletionToolWithCache } from './types.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
// Mock OpenAI
vi.mock('openai', () => ({
@@ -34,10 +32,6 @@ vi.mock('openai', () => ({
})),
}));
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
buildRuntimeFetchOptions: vi.fn(),
}));
describe('DashScopeOpenAICompatibleProvider', () => {
let provider: DashScopeOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
@@ -45,11 +39,6 @@ describe('DashScopeOpenAICompatibleProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
// Mock ContentGeneratorConfig
mockContentGeneratorConfig = {
@@ -196,20 +185,18 @@ describe('DashScopeOpenAICompatibleProvider', () => {
it('should create OpenAI client with DashScope configuration', () => {
const client = provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-CacheControl': 'enable',
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
},
}),
);
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-CacheControl': 'enable',
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
},
});
expect(client).toBeDefined();
});
@@ -220,15 +207,13 @@ describe('DashScopeOpenAICompatibleProvider', () => {
provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: expect.any(Object),
}),
);
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: expect.any(Object),
});
});
});

View File

@@ -16,7 +16,6 @@ import type {
ChatCompletionContentPartWithCache,
ChatCompletionToolWithCache,
} from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
export class DashScopeOpenAICompatibleProvider
implements OpenAICompatibleProvider
@@ -69,16 +68,12 @@ export class DashScopeOpenAICompatibleProvider
maxRetries = DEFAULT_MAX_RETRIES,
} = this.contentGeneratorConfig;
const defaultHeaders = this.buildHeaders();
// Configure fetch options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
const fetchOptions = buildRuntimeFetchOptions('openai');
return new OpenAI({
apiKey,
baseURL: baseUrl,
timeout,
maxRetries,
defaultHeaders,
...(fetchOptions ? { fetchOptions } : {}),
});
}

View File

@@ -17,8 +17,6 @@ import { DefaultOpenAICompatibleProvider } from './default.js';
import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
// Mock OpenAI
vi.mock('openai', () => ({
@@ -32,10 +30,6 @@ vi.mock('openai', () => ({
})),
}));
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
buildRuntimeFetchOptions: vi.fn(),
}));
describe('DefaultOpenAICompatibleProvider', () => {
let provider: DefaultOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
@@ -43,11 +37,6 @@ describe('DefaultOpenAICompatibleProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
// Mock ContentGeneratorConfig
mockContentGeneratorConfig = {
@@ -123,17 +112,15 @@ describe('DefaultOpenAICompatibleProvider', () => {
it('should create OpenAI client with correct configuration', () => {
const client = provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
}),
);
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: 60000,
maxRetries: 2,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
});
expect(client).toBeDefined();
});
@@ -144,17 +131,15 @@ describe('DefaultOpenAICompatibleProvider', () => {
provider.buildClient();
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
}),
);
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-api-key',
baseURL: 'https://api.openai.com/v1',
timeout: DEFAULT_TIMEOUT,
maxRetries: DEFAULT_MAX_RETRIES,
defaultHeaders: {
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
},
});
});
it('should include custom headers from buildHeaders', () => {

View File

@@ -4,7 +4,6 @@ import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import type { OpenAICompatibleProvider } from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
/**
* Default provider for standard OpenAI-compatible APIs
@@ -44,16 +43,12 @@ export class DefaultOpenAICompatibleProvider
maxRetries = DEFAULT_MAX_RETRIES,
} = this.contentGeneratorConfig;
const defaultHeaders = this.buildHeaders();
// Configure fetch options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
const fetchOptions = buildRuntimeFetchOptions('openai');
return new OpenAI({
apiKey,
baseURL: baseUrl,
timeout,
maxRetries,
defaultHeaders,
...(fetchOptions ? { fetchOptions } : {}),
});
}

View File

@@ -1,167 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { EnvHttpProxyAgent } from 'undici';
/**
* JavaScript runtime type
*/
export type Runtime = 'node' | 'bun' | 'unknown';
/**
* Detect the current JavaScript runtime
*/
export function detectRuntime(): Runtime {
if (typeof process !== 'undefined' && process.versions?.['bun']) {
return 'bun';
}
if (typeof process !== 'undefined' && process.versions?.node) {
return 'node';
}
return 'unknown';
}
/**
* Runtime fetch options for OpenAI SDK
*/
export type OpenAIRuntimeFetchOptions =
| {
dispatcher?: EnvHttpProxyAgent;
timeout?: false;
}
| undefined;
/**
* Runtime fetch options for Anthropic SDK
*/
export type AnthropicRuntimeFetchOptions = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpAgent?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetch?: any;
};
/**
* SDK type identifier
*/
export type SDKType = 'openai' | 'anthropic';
/**
* Build runtime-specific fetch options for OpenAI SDK
*/
export function buildRuntimeFetchOptions(
sdkType: 'openai',
): OpenAIRuntimeFetchOptions;
/**
* Build runtime-specific fetch options for Anthropic SDK
*/
export function buildRuntimeFetchOptions(
sdkType: 'anthropic',
): AnthropicRuntimeFetchOptions;
/**
* Build runtime-specific fetch options based on the detected runtime and SDK type
* This function applies runtime-specific configurations to handle timeout differences
* across Node.js and Bun, ensuring user-configured timeout works as expected.
*
* @param sdkType - The SDK type ('openai' or 'anthropic') to determine return type
* @returns Runtime-specific options compatible with the specified SDK
*/
export function buildRuntimeFetchOptions(
sdkType: SDKType,
): OpenAIRuntimeFetchOptions | AnthropicRuntimeFetchOptions {
const runtime = detectRuntime();
// Always disable bodyTimeout (set to 0) to let SDK's timeout parameter
// control the total request time. bodyTimeout only monitors intervals between
// data chunks, not the total request time, so we disable it to ensure user-configured
// timeout works as expected for both streaming and non-streaming requests.
switch (runtime) {
case 'bun': {
if (sdkType === 'openai') {
// Bun: Disable built-in 300s timeout to let OpenAI SDK timeout control
// This ensures user-configured timeout works as expected without interference
return {
timeout: false,
};
} else {
// Bun: Use custom fetch to disable built-in 300s timeout
// This allows Anthropic SDK timeout to control the request
// Note: Bun's fetch automatically uses proxy settings from environment variables
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), so proxy behavior is preserved
const bunFetch: typeof fetch = async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const bunFetchOptions: RequestInit = {
...init,
// @ts-expect-error - Bun-specific timeout option
timeout: false,
};
return fetch(input, bunFetchOptions);
};
return {
fetch: bunFetch,
};
}
}
case 'node': {
// Node.js: Use EnvHttpProxyAgent to configure proxy and disable bodyTimeout
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) to preserve proxy functionality
// bodyTimeout is always 0 (disabled) to let SDK timeout control the request
try {
const agent = new EnvHttpProxyAgent({
bodyTimeout: 0, // Disable to let SDK timeout control total request time
});
if (sdkType === 'openai') {
return {
dispatcher: agent,
};
} else {
return {
httpAgent: agent,
};
}
} catch {
// If undici is not available, return appropriate default
if (sdkType === 'openai') {
return undefined;
} else {
return {};
}
}
}
default: {
// Unknown runtime: Try to use EnvHttpProxyAgent if available
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
try {
const agent = new EnvHttpProxyAgent({
bodyTimeout: 0, // Disable to let SDK timeout control total request time
});
if (sdkType === 'openai') {
return {
dispatcher: agent,
};
} else {
return {
httpAgent: agent,
};
}
} catch {
if (sdkType === 'openai') {
return undefined;
} else {
return {};
}
}
}
}
}

View File

@@ -14,6 +14,7 @@ export const AGENT_METHODS = {
session_prompt: 'session/prompt',
session_save: 'session/save',
session_set_mode: 'session/set_mode',
session_set_model: 'session/set_model',
} as const;
export const CLIENT_METHODS = {

View File

@@ -401,6 +401,21 @@ export class AcpConnection {
);
}
/**
* Set model for current session
*
* @param modelId - Model ID
* @returns Set model response
*/
async setModel(modelId: string): Promise<AcpResponse> {
return this.sessionManager.setModel(
modelId,
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* Disconnect
*/

View File

@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AcpSessionManager } from './acpSessionManager.js';
import type { ChildProcess } from 'child_process';
import type { PendingRequest } from '../types/connectionTypes.js';
import { AGENT_METHODS } from '../constants/acpSchema.js';
describe('AcpSessionManager', () => {
let sessionManager: AcpSessionManager;
let mockChild: ChildProcess;
let pendingRequests: Map<number, PendingRequest<unknown>>;
let nextRequestId: { value: number };
let writtenMessages: string[];
beforeEach(() => {
sessionManager = new AcpSessionManager();
writtenMessages = [];
mockChild = {
stdin: {
write: vi.fn((msg: string) => {
writtenMessages.push(msg);
// Simulate async response
const parsed = JSON.parse(msg.trim());
const id = parsed.id;
setTimeout(() => {
const pending = pendingRequests.get(id);
if (pending) {
pending.resolve({ modeId: 'default', modelId: 'test-model' });
pendingRequests.delete(id);
}
}, 10);
}),
},
} as unknown as ChildProcess;
pendingRequests = new Map();
nextRequestId = { value: 0 };
});
describe('setModel', () => {
it('sends session/set_model request with correct parameters', async () => {
// First initialize the session
// @ts-expect-error - accessing private property for testing
sessionManager.sessionId = 'test-session-id';
const responsePromise = sessionManager.setModel(
'qwen3-coder-plus',
mockChild,
pendingRequests,
nextRequestId,
);
// Wait for the response
const response = await responsePromise;
// Verify the message was sent
expect(writtenMessages.length).toBe(1);
const sentMessage = JSON.parse(writtenMessages[0].trim());
expect(sentMessage.method).toBe(AGENT_METHODS.session_set_model);
expect(sentMessage.params).toEqual({
sessionId: 'test-session-id',
modelId: 'qwen3-coder-plus',
});
expect(response).toEqual({ modeId: 'default', modelId: 'test-model' });
});
it('throws error when no active session', async () => {
await expect(
sessionManager.setModel(
'qwen3-coder-plus',
mockChild,
pendingRequests,
nextRequestId,
),
).rejects.toThrow('No active ACP session');
});
it('increments request ID for each call', async () => {
// @ts-expect-error - accessing private property for testing
sessionManager.sessionId = 'test-session-id';
await sessionManager.setModel(
'model-1',
mockChild,
pendingRequests,
nextRequestId,
);
await sessionManager.setModel(
'model-2',
mockChild,
pendingRequests,
nextRequestId,
);
const firstMessage = JSON.parse(writtenMessages[0].trim());
const secondMessage = JSON.parse(writtenMessages[1].trim());
expect(firstMessage.id).toBe(0);
expect(secondMessage.id).toBe(1);
});
});
describe('setMode', () => {
it('sends session/set_mode request with correct parameters', async () => {
// @ts-expect-error - accessing private property for testing
sessionManager.sessionId = 'test-session-id';
const responsePromise = sessionManager.setMode(
'auto-edit',
mockChild,
pendingRequests,
nextRequestId,
);
const response = await responsePromise;
expect(writtenMessages.length).toBe(1);
const sentMessage = JSON.parse(writtenMessages[0].trim());
expect(sentMessage.method).toBe(AGENT_METHODS.session_set_mode);
expect(sentMessage.params).toEqual({
sessionId: 'test-session-id',
modeId: 'auto-edit',
});
expect(response).toBeDefined();
});
it('throws error when no active session', async () => {
await expect(
sessionManager.setMode(
'default',
mockChild,
pendingRequests,
nextRequestId,
),
).rejects.toThrow('No active ACP session');
});
});
});

View File

@@ -375,6 +375,32 @@ export class AcpSessionManager {
return res;
}
/**
* Set model for current session (ACP session/set_model)
*
* @param modelId - Model ID
*/
async setModel(
modelId: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
console.log('[ACP] Sending session/set_model:', modelId);
const res = await this.sendRequest<AcpResponse>(
AGENT_METHODS.session_set_model,
{ sessionId: this.sessionId, modelId },
child,
pendingRequests,
nextRequestId,
);
console.log('[ACP] set_model response:', res);
return res;
}
/**
* Switch to specified session
*

View File

@@ -9,6 +9,7 @@ import type {
AcpPermissionRequest,
AuthenticateUpdateNotification,
ModelInfo,
AvailableCommand,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
@@ -26,7 +27,10 @@ import {
} from '../services/qwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { authMethod } from '../types/acpTypes.js';
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
import {
extractModelInfoFromNewSessionResult,
extractSessionModelState,
} from '../utils/acpModelInfo.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
@@ -207,6 +211,16 @@ export class QwenAgentManager {
if (res.modelInfo && this.callbacks.onModelInfo) {
this.callbacks.onModelInfo(res.modelInfo);
}
// Emit available models from connect result
if (res.availableModels && res.availableModels.length > 0) {
console.log(
'[QwenAgentManager] Emitting availableModels from connect():',
res.availableModels.map((m) => m.modelId),
);
if (this.callbacks.onAvailableModels) {
this.callbacks.onAvailableModels(res.availableModels);
}
}
return res;
}
@@ -245,6 +259,27 @@ export class QwenAgentManager {
}
}
/**
* Set model from UI
*/
async setModelFromUi(modelId: string): Promise<ModelInfo | null> {
try {
const res = await this.connection.setModel(modelId);
// Parse response and notify UI
const result = (res?.result || {}) as { modelId?: string };
const confirmedModelId = result.modelId || modelId;
const modelInfo: ModelInfo = {
modelId: confirmedModelId,
name: confirmedModelId,
};
this.callbacks.onModelChanged?.(modelInfo);
return modelInfo;
} catch (err) {
console.error('[QwenAgentManager] Failed to set model:', err);
throw err;
}
}
/**
* Validate if current session is still active
* This is a lightweight check to verify session validity
@@ -1087,10 +1122,17 @@ export class QwenAgentManager {
const autoAuthenticate = options?.autoAuthenticate ?? true;
// Reuse existing session if present
if (this.connection.currentSessionId) {
console.log(
'[QwenAgentManager] createNewSession: reusing existing session',
this.connection.currentSessionId,
);
return this.connection.currentSessionId;
}
// Deduplicate concurrent session/new attempts
if (this.sessionCreateInFlight) {
console.log(
'[QwenAgentManager] createNewSession: session creation already in flight',
);
return this.sessionCreateInFlight;
}
@@ -1102,6 +1144,10 @@ export class QwenAgentManager {
// Try to create a new ACP session. If Qwen asks for auth, let it handle authentication.
try {
newSessionResult = await this.connection.newSession(workingDir);
console.log(
'[QwenAgentManager] newSession returned:',
JSON.stringify(newSessionResult, null, 2),
);
} catch (err) {
const requiresAuth = isAuthenticationRequiredError(err);
@@ -1142,6 +1188,30 @@ export class QwenAgentManager {
this.callbacks.onModelInfo(modelInfo);
}
// Extract and emit available models
const modelState = extractSessionModelState(newSessionResult);
console.log(
'[QwenAgentManager] Extracted model state from session/new:',
modelState,
);
if (
modelState?.availableModels &&
modelState.availableModels.length > 0
) {
console.log(
'[QwenAgentManager] Emitting availableModels:',
modelState.availableModels,
);
if (this.callbacks.onAvailableModels) {
this.callbacks.onAvailableModels(modelState.availableModels);
}
} else {
console.warn(
'[QwenAgentManager] No availableModels found in session/new response. Raw models field:',
(newSessionResult as Record<string, unknown>)?.models,
);
}
const newSessionId = this.connection.currentSessionId;
console.log(
'[QwenAgentManager] New session created with ID:',
@@ -1288,6 +1358,30 @@ export class QwenAgentManager {
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for model changed updates (from ACP current_model_update)
*/
onModelChanged(callback: (model: ModelInfo) => void): void {
this.callbacks.onModelChanged = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for available commands updates (from ACP available_commands_update)
*/
onAvailableCommands(callback: (commands: AvailableCommand[]) => void): void {
this.callbacks.onAvailableCommands = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for available models updates (from session/new response)
*/
onAvailableModels(callback: (models: ModelInfo[]) => void): void {
this.callbacks.onAvailableModels = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Disconnect
*/

View File

@@ -13,13 +13,17 @@
import type { AcpConnection } from './acpConnection.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { authMethod } from '../types/acpTypes.js';
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
import {
extractModelInfoFromNewSessionResult,
extractSessionModelState,
} from '../utils/acpModelInfo.js';
import type { ModelInfo } from '../types/acpTypes.js';
export interface QwenConnectionResult {
sessionCreated: boolean;
requiresAuth: boolean;
modelInfo?: ModelInfo;
availableModels?: ModelInfo[];
}
/**
@@ -48,6 +52,7 @@ export class QwenConnectionHandler {
let sessionCreated = false;
let requiresAuth = false;
let modelInfo: ModelInfo | undefined;
let availableModels: ModelInfo[] | undefined;
// Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = [];
@@ -79,6 +84,20 @@ export class QwenConnectionHandler {
);
modelInfo =
extractModelInfoFromNewSessionResult(newSessionResult) || undefined;
// Extract available models from session/new response
const modelState = extractSessionModelState(newSessionResult);
if (
modelState?.availableModels &&
modelState.availableModels.length > 0
) {
availableModels = modelState.availableModels;
console.log(
'[QwenAgentManager] Extracted availableModels from session/new:',
availableModels.map((m) => m.modelId),
);
}
console.log('[QwenAgentManager] New session created successfully');
sessionCreated = true;
} catch (sessionError) {
@@ -105,7 +124,7 @@ export class QwenConnectionHandler {
console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`);
return { sessionCreated, requiresAuth, modelInfo };
return { sessionCreated, requiresAuth, modelInfo, availableModels };
}
/**

View File

@@ -0,0 +1,379 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import type { AcpSessionUpdate } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
describe('QwenSessionUpdateHandler', () => {
let handler: QwenSessionUpdateHandler;
let mockCallbacks: QwenAgentCallbacks;
beforeEach(() => {
mockCallbacks = {
onStreamChunk: vi.fn(),
onThoughtChunk: vi.fn(),
onToolCall: vi.fn(),
onPlan: vi.fn(),
onModeChanged: vi.fn(),
onModelChanged: vi.fn(),
onUsageUpdate: vi.fn(),
onAvailableCommands: vi.fn(),
};
handler = new QwenSessionUpdateHandler(mockCallbacks);
});
describe('current_model_update handling', () => {
it('calls onModelChanged callback with model info', () => {
const modelUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: 'A powerful coding model',
},
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(modelUpdate);
expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: 'A powerful coding model',
});
});
it('handles model update with _meta field', () => {
const modelUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'test-model',
name: 'Test Model',
_meta: { contextLimit: 128000 },
},
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(modelUpdate);
expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({
modelId: 'test-model',
name: 'Test Model',
_meta: { contextLimit: 128000 },
});
});
it('does not call callback when onModelChanged is not set', () => {
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
const modelUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'qwen3-coder',
name: 'Qwen3 Coder',
},
},
} as AcpSessionUpdate;
// Should not throw
expect(() =>
handlerWithoutCallback.handleSessionUpdate(modelUpdate),
).not.toThrow();
});
});
describe('current_mode_update handling', () => {
it('calls onModeChanged callback with mode id', () => {
const modeUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_mode_update',
modeId: 'auto-edit' as ApprovalModeValue,
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(modeUpdate);
expect(mockCallbacks.onModeChanged).toHaveBeenCalledWith('auto-edit');
});
});
describe('agent_message_chunk handling', () => {
it('calls onStreamChunk callback with text content', () => {
const messageUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Hello, world!',
},
},
};
handler.handleSessionUpdate(messageUpdate);
expect(mockCallbacks.onStreamChunk).toHaveBeenCalledWith('Hello, world!');
});
it('emits usage metadata when present', () => {
const messageUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Response',
},
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
totalTokens: 150,
},
durationMs: 1234,
},
},
};
handler.handleSessionUpdate(messageUpdate);
expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({
usage: {
promptTokens: 100,
completionTokens: 50,
totalTokens: 150,
},
durationMs: 1234,
});
});
});
describe('tool_call handling', () => {
it('calls onToolCall callback with tool call data', () => {
const toolCallUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
kind: 'read',
title: 'Read file',
status: 'pending',
rawInput: { path: '/test/file.ts' },
},
};
handler.handleSessionUpdate(toolCallUpdate);
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith({
toolCallId: 'call-123',
kind: 'read',
title: 'Read file',
status: 'pending',
rawInput: { path: '/test/file.ts' },
content: undefined,
locations: undefined,
});
});
});
describe('plan handling', () => {
it('calls onPlan callback with plan entries', () => {
const planUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'plan',
entries: [
{ content: 'Step 1', priority: 'high', status: 'pending' },
{ content: 'Step 2', priority: 'medium', status: 'pending' },
],
},
};
handler.handleSessionUpdate(planUpdate);
expect(mockCallbacks.onPlan).toHaveBeenCalledWith([
{ content: 'Step 1', priority: 'high', status: 'pending' },
{ content: 'Step 2', priority: 'medium', status: 'pending' },
]);
});
it('falls back to stream chunk when onPlan is not set', () => {
const handlerWithStream = new QwenSessionUpdateHandler({
onStreamChunk: vi.fn(),
});
const planUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'plan',
entries: [{ content: 'Task 1', priority: 'high', status: 'pending' }],
},
};
handlerWithStream.handleSessionUpdate(planUpdate);
expect(handlerWithStream['callbacks'].onStreamChunk).toHaveBeenCalled();
});
});
describe('available_commands_update handling', () => {
it('calls onAvailableCommands callback with commands', () => {
const commandsUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{
name: 'compress',
description: 'Compress the context',
input: null,
},
{
name: 'init',
description: 'Initialize the project',
input: null,
},
{
name: 'summary',
description: 'Generate project summary',
input: null,
},
],
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(commandsUpdate);
expect(mockCallbacks.onAvailableCommands).toHaveBeenCalledWith([
{ name: 'compress', description: 'Compress the context', input: null },
{ name: 'init', description: 'Initialize the project', input: null },
{
name: 'summary',
description: 'Generate project summary',
input: null,
},
]);
});
it('handles commands with input hint', () => {
const commandsUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{
name: 'search',
description: 'Search for files',
input: { hint: 'Enter search query' },
},
],
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(commandsUpdate);
expect(mockCallbacks.onAvailableCommands).toHaveBeenCalledWith([
{
name: 'search',
description: 'Search for files',
input: { hint: 'Enter search query' },
},
]);
});
it('does not call callback when onAvailableCommands is not set', () => {
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
const commandsUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'compress', description: 'Compress', input: null },
],
},
} as AcpSessionUpdate;
// Should not throw
expect(() =>
handlerWithoutCallback.handleSessionUpdate(commandsUpdate),
).not.toThrow();
});
it('handles empty commands list', () => {
const commandsUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [],
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(commandsUpdate);
expect(mockCallbacks.onAvailableCommands).toHaveBeenCalledWith([]);
});
});
describe('updateCallbacks', () => {
it('updates callbacks and uses new ones', () => {
const newOnModelChanged = vi.fn();
handler.updateCallbacks({
...mockCallbacks,
onModelChanged: newOnModelChanged,
});
const modelUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'new-model',
name: 'New Model',
},
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(modelUpdate);
expect(newOnModelChanged).toHaveBeenCalled();
expect(mockCallbacks.onModelChanged).not.toHaveBeenCalled();
});
it('updates onAvailableCommands callback', () => {
const newOnAvailableCommands = vi.fn();
handler.updateCallbacks({
...mockCallbacks,
onAvailableCommands: newOnAvailableCommands,
});
const commandsUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'test', description: 'Test command', input: null },
],
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(commandsUpdate);
expect(newOnAvailableCommands).toHaveBeenCalled();
expect(mockCallbacks.onAvailableCommands).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,7 +10,12 @@
* Handles session updates from ACP and dispatches them to appropriate callbacks
*/
import type { AcpSessionUpdate, SessionUpdateMeta } from '../types/acpTypes.js';
import type {
AcpSessionUpdate,
SessionUpdateMeta,
ModelInfo,
AvailableCommand,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type {
QwenAgentCallbacks,
@@ -160,6 +165,40 @@ export class QwenSessionUpdateHandler {
break;
}
case 'current_model_update': {
// Notify UI about model change
try {
const model = (update as unknown as { model?: ModelInfo }).model;
if (model && this.callbacks.onModelChanged) {
this.callbacks.onModelChanged(model);
}
} catch (err) {
console.warn(
'[SessionUpdateHandler] Failed to handle model update',
err,
);
}
break;
}
case 'available_commands_update': {
// Notify UI about available commands
try {
const commands = (
update as unknown as { availableCommands?: AvailableCommand[] }
).availableCommands;
if (commands && this.callbacks.onAvailableCommands) {
this.callbacks.onAvailableCommands(commands);
}
} catch (err) {
console.warn(
'[SessionUpdateHandler] Failed to handle available commands update',
err,
);
}
break;
}
default:
console.log('[QwenAgentManager] Unhandled session update type');
break;

View File

@@ -197,6 +197,31 @@ export interface CurrentModeUpdate extends BaseSessionUpdate {
};
}
// Current model update (sent by agent when model changes)
export interface CurrentModelUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'current_model_update';
model: ModelInfo;
};
}
// Available command definition
export interface AvailableCommand {
name: string;
description: string;
input?: {
hint?: string;
} | null;
}
// Available commands update (sent by agent after session creation)
export interface AvailableCommandsUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'available_commands_update';
availableCommands: AvailableCommand[];
};
}
// Authenticate update (sent by agent during authentication process)
export interface AuthenticateUpdateNotification {
_meta: {
@@ -211,7 +236,9 @@ export type AcpSessionUpdate =
| ToolCallUpdate
| ToolCallStatusUpdate
| PlanUpdate
| CurrentModeUpdate;
| CurrentModeUpdate
| CurrentModelUpdate
| AvailableCommandsUpdate;
// Permission request (simplified version, use schema.RequestPermissionRequest for validation)
export interface AcpPermissionRequest {

View File

@@ -3,7 +3,11 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpPermissionRequest, ModelInfo } from './acpTypes.js';
import type {
AcpPermissionRequest,
ModelInfo,
AvailableCommand,
} from './acpTypes.js';
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export interface ChatMessage {
@@ -59,6 +63,9 @@ export interface QwenAgentCallbacks {
onModeChanged?: (modeId: ApprovalModeValue) => void;
onUsageUpdate?: (stats: UsageStatsPayload) => void;
onModelInfo?: (info: ModelInfo) => void;
onModelChanged?: (model: ModelInfo) => void;
onAvailableCommands?: (commands: AvailableCommand[]) => void;
onAvailableModels?: (models: ModelInfo[]) => void;
}
export interface ToolCallUpdate {

View File

@@ -16,4 +16,14 @@ export interface CompletionItem {
value?: string;
// Optional full path for files (used to build @filename -> full path mapping)
path?: string;
// Optional group name for grouping items in the completion menu
group?: string;
}
/**
* Grouped completion items for display
*/
export interface CompletionGroup {
name: string;
items: CompletionItem[];
}

View File

@@ -5,7 +5,138 @@
*/
import { describe, expect, it } from 'vitest';
import { extractModelInfoFromNewSessionResult } from './acpModelInfo.js';
import {
extractModelInfoFromNewSessionResult,
extractSessionModelState,
} from './acpModelInfo.js';
describe('extractSessionModelState', () => {
it('extracts full model state from NewSessionResponse.models', () => {
const result = extractSessionModelState({
sessionId: 's',
models: {
currentModelId: 'qwen3-coder-plus',
availableModels: [
{
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: null,
_meta: { contextLimit: 123 },
},
{
modelId: 'qwen3-coder',
name: 'Qwen3 Coder',
description: 'Standard model',
_meta: { contextLimit: 64 },
},
],
},
});
expect(result).toEqual({
currentModelId: 'qwen3-coder-plus',
availableModels: [
{
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: null,
_meta: { contextLimit: 123 },
},
{
modelId: 'qwen3-coder',
name: 'Qwen3 Coder',
description: 'Standard model',
_meta: { contextLimit: 64 },
},
],
});
});
it('returns all available models', () => {
const result = extractSessionModelState({
models: {
currentModelId: 'model-a',
availableModels: [
{ modelId: 'model-a', name: 'Model A' },
{ modelId: 'model-b', name: 'Model B' },
{ modelId: 'model-c', name: 'Model C' },
],
},
});
expect(result?.availableModels).toHaveLength(3);
expect(result?.availableModels.map((m) => m.modelId)).toEqual([
'model-a',
'model-b',
'model-c',
]);
});
it('defaults to first model if currentModelId is missing', () => {
const result = extractSessionModelState({
models: {
availableModels: [
{ modelId: 'first', name: 'First Model' },
{ modelId: 'second', name: 'Second Model' },
],
},
});
expect(result?.currentModelId).toBe('first');
});
it('handles legacy array format', () => {
const result = extractSessionModelState({
models: [
{ modelId: 'legacy-1', name: 'Legacy 1' },
{ modelId: 'legacy-2', name: 'Legacy 2' },
],
});
expect(result).toEqual({
currentModelId: 'legacy-1',
availableModels: [
{ modelId: 'legacy-1', name: 'Legacy 1' },
{ modelId: 'legacy-2', name: 'Legacy 2' },
],
});
});
it('filters out invalid model entries', () => {
const result = extractSessionModelState({
models: {
currentModelId: 'valid',
availableModels: [
{ name: '', modelId: '' }, // invalid
{ modelId: 'valid', name: 'Valid Model' },
{}, // invalid
],
},
});
expect(result?.availableModels).toHaveLength(1);
expect(result?.availableModels[0].modelId).toBe('valid');
});
it('returns null when models field is missing', () => {
expect(extractSessionModelState({})).toBeNull();
expect(extractSessionModelState(null)).toBeNull();
expect(extractSessionModelState({ sessionId: 's' })).toBeNull();
});
it('returns null when availableModels is empty after filtering', () => {
const result = extractSessionModelState({
models: {
currentModelId: 'none',
availableModels: [{ name: '', modelId: '' }, { name: '' }],
},
});
// When all models are invalid, availableModels will be empty
// The function should still return a state with empty availableModels
expect(result?.availableModels).toHaveLength(0);
});
});
describe('extractModelInfoFromNewSessionResult', () => {
it('extracts from NewSessionResponse.models (SessionModelState)', () => {

View File

@@ -69,6 +69,69 @@ const normalizeModelInfo = (value: unknown): ModelInfo | null => {
};
};
/**
* SessionModelState as returned from ACP session/new.
*/
export interface SessionModelState {
availableModels: ModelInfo[];
currentModelId: string;
}
/**
* Extract complete model state from ACP `session/new` result.
*
* Returns both the list of available models and the current model ID.
*/
export const extractSessionModelState = (
result: unknown,
): SessionModelState | null => {
if (!result || typeof result !== 'object') {
return null;
}
const obj = result as Record<string, unknown>;
const models = obj['models'];
// ACP draft: NewSessionResponse.models is a SessionModelState object.
if (models && typeof models === 'object' && !Array.isArray(models)) {
const state = models as Record<string, unknown>;
const availableModels = state['availableModels'];
const currentModelId = state['currentModelId'];
if (Array.isArray(availableModels)) {
const normalizedModels = availableModels
.map(normalizeModelInfo)
.filter((m): m is ModelInfo => Boolean(m));
const modelId =
typeof currentModelId === 'string' && currentModelId.length > 0
? currentModelId
: normalizedModels[0]?.modelId || '';
return {
availableModels: normalizedModels,
currentModelId: modelId,
};
}
}
// Legacy: some implementations returned `models` as a raw array.
if (Array.isArray(models)) {
const normalizedModels = models
.map(normalizeModelInfo)
.filter((m): m is ModelInfo => Boolean(m));
if (normalizedModels.length > 0) {
return {
availableModels: normalizedModels,
currentModelId: normalizedModels[0].modelId,
};
}
}
return null;
};
/**
* Extract model info from ACP `session/new` result.
*

View File

@@ -42,7 +42,8 @@ import {
} from './components/messages/index.js';
import { InputForm } from './components/layout/InputForm.js';
import { SessionSelector } from './components/layout/SessionSelector.js';
import { FileIcon, UserIcon } from './components/icons/index.js';
import { FileIcon } from './components/icons/index.js';
import type { AvailableCommand } from '../types/acpTypes.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js';
@@ -77,6 +78,11 @@ export const App: React.FC = () => {
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
const [modelInfo, setModelInfo] = useState<ModelInfo | null>(null);
const [usageStats, setUsageStats] = useState<UsageStatsPayload | null>(null);
const [availableCommands, setAvailableCommands] = useState<
AvailableCommand[]
>([]);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [showModelSelector, setShowModelSelector] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(
null,
) as React.RefObject<HTMLDivElement>;
@@ -146,23 +152,58 @@ export const App: React.FC = () => {
return allItems;
} else {
// Handle slash commands
const commands: CompletionItem[] = [
// Handle slash commands with grouping
// Model group - special items without / prefix
const modelGroupItems: CompletionItem[] = [
{
id: 'login',
label: '/login',
description: 'Login to Qwen Code',
id: 'model',
label: 'Switch model...',
description: modelInfo?.name || 'Default',
type: 'command',
icon: <UserIcon />,
group: 'Model',
},
];
return commands.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase()),
// Account group
const accountGroupItems: CompletionItem[] = [
{
id: 'login',
label: 'Login',
description: 'Login to Qwen Code',
type: 'command',
group: 'Account',
},
];
// Slash Commands group - commands from server (available_commands_update)
const slashCommandItems: CompletionItem[] = availableCommands.map(
(cmd) => ({
id: cmd.name,
label: `/${cmd.name}`,
description: cmd.description,
type: 'command' as const,
group: 'Slash Commands',
}),
);
// Combine all commands
const allCommands = [
...modelGroupItems,
...accountGroupItems,
...slashCommandItems,
];
// Filter by query
const lowerQuery = query.toLowerCase();
return allCommands.filter(
(cmd) =>
cmd.label.toLowerCase().includes(lowerQuery) ||
(cmd.description &&
cmd.description.toLowerCase().includes(lowerQuery)),
);
}
},
[fileContext],
[fileContext, availableCommands, modelInfo?.name],
);
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
@@ -301,6 +342,12 @@ export const App: React.FC = () => {
setModelInfo: (info) => {
setModelInfo(info);
},
setAvailableCommands: (commands) => {
setAvailableCommands(commands);
},
setAvailableModels: (models) => {
setAvailableModels(models);
},
});
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
@@ -451,14 +498,94 @@ export const App: React.FC = () => {
return;
}
// Slash commands can execute immediately
// Commands can execute immediately
if (item.type === 'command') {
const command = (item.label || '').trim();
if (command === '/login') {
const itemId = item.id;
// Helper to clear trigger text from input
const clearTriggerText = () => {
const text = inputElement.textContent || '';
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// Fallback: just clear everything
inputElement.textContent = '';
setInputText('');
return;
}
// Find and remove the slash command trigger
const range = selection.getRangeAt(0);
let cursorPos = text.length;
if (range.startContainer === inputElement) {
const childIndex = range.startOffset;
let offset = 0;
for (
let i = 0;
i < childIndex && i < inputElement.childNodes.length;
i++
) {
offset += inputElement.childNodes[i].textContent?.length || 0;
}
cursorPos = offset || text.length;
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
const walker = document.createTreeWalker(
inputElement,
NodeFilter.SHOW_TEXT,
null,
);
let offset = 0;
let found = false;
let node: Node | null = walker.nextNode();
while (node) {
if (node === range.startContainer) {
offset += range.startOffset;
found = true;
break;
}
offset += node.textContent?.length || 0;
node = walker.nextNode();
}
cursorPos = found ? offset : text.length;
}
const textBeforeCursor = text.substring(0, cursorPos);
const slashPos = textBeforeCursor.lastIndexOf('/');
if (slashPos >= 0) {
const newText =
text.substring(0, slashPos) + text.substring(cursorPos);
inputElement.textContent = newText;
setInputText(newText);
}
};
// Handle special commands by id
if (itemId === 'login') {
clearTriggerText();
vscode.postMessage({ type: 'login', data: {} });
completion.closeCompletion();
return;
}
if (itemId === 'model') {
clearTriggerText();
setShowModelSelector(true);
completion.closeCompletion();
return;
}
// Handle server-provided slash commands by sending them as messages
// CLI will detect slash commands in session/prompt and execute them
const serverCmd = availableCommands.find((c) => c.name === itemId);
if (serverCmd) {
// Clear the trigger text since we're sending the command
clearTriggerText();
// Send the slash command as a user message
vscode.postMessage({
type: 'sendMessage',
data: { text: `/${serverCmd.name}` },
});
completion.closeCompletion();
return;
}
}
// If selecting a file, add @filename -> fullpath mapping
@@ -543,7 +670,25 @@ export const App: React.FC = () => {
// Close the completion menu
completion.closeCompletion();
},
[completion, inputFieldRef, setInputText, fileContext, vscode],
[
completion,
inputFieldRef,
setInputText,
fileContext,
vscode,
availableCommands,
],
);
// Handle model selection
const handleModelSelect = useCallback(
(modelId: string) => {
vscode.postMessage({
type: 'setModel',
data: { modelId },
});
},
[vscode],
);
// Handle attach context click
@@ -866,6 +1011,11 @@ export const App: React.FC = () => {
completionItems={completion.items}
onCompletionSelect={handleCompletionSelect}
onCompletionClose={completion.closeCompletion}
showModelSelector={showModelSelector}
availableModels={availableModels}
currentModelId={modelInfo?.modelId}
onSelectModel={handleModelSelect}
onCloseModelSelector={() => setShowModelSelector(false)}
/>
)}

View File

@@ -132,6 +132,34 @@ export class WebViewProvider {
});
});
// Surface model changes (from ACP current_model_update or set_model response)
this.agentManager.onModelChanged((model) => {
this.sendMessageToWebView({
type: 'modelChanged',
data: { model },
});
});
// Surface available commands (from ACP available_commands_update)
this.agentManager.onAvailableCommands((commands) => {
this.sendMessageToWebView({
type: 'availableCommands',
data: { commands },
});
});
// Surface available models (from session/new response)
this.agentManager.onAvailableModels((models) => {
console.log(
'[WebViewProvider] onAvailableModels received, sending to webview:',
models,
);
this.sendMessageToWebView({
type: 'availableModels',
data: { models },
});
});
// Setup end-turn handler from ACP stopReason notifications
this.agentManager.onEndTurn((reason) => {
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere

View File

@@ -186,3 +186,54 @@ export const SelectionIcon: React.FC<IconProps> = ({
<path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Z" />
</svg>
);
/**
* Check icon (16x16)
* Used for selected items
*/
export const CheckIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Model icon (16x16)
* Used for model selection command
*/
export const ModelIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M8 1a.75.75 0 0 1 .75.75V6h4.5a.75.75 0 0 1 0 1.5h-4.5v4.25a.75.75 0 0 1-1.5 0V7.5h-4.5a.75.75 0 0 1 0-1.5h4.5V1.75A.75.75 0 0 1 8 1Z" />
<path d="M2 14.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" />
</svg>
);

View File

@@ -40,6 +40,8 @@ export {
UserIcon,
SymbolIcon,
SelectionIcon,
CheckIcon,
ModelIcon,
} from './StatusIcons.js';
// Special icons

View File

@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react';
import type { CompletionItem } from '../../../types/completionItemTypes.js';
interface CompletionMenuProps {
@@ -16,6 +16,28 @@ interface CompletionMenuProps {
selectedIndex?: number;
}
/**
* Group items by their group property
*/
const groupItems = (
items: CompletionItem[],
): Array<{ group: string | null; items: CompletionItem[] }> => {
const groups: Map<string | null, CompletionItem[]> = new Map();
for (const item of items) {
const groupKey = item.group || null;
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(item);
}
return Array.from(groups.entries()).map(([group, groupItems]) => ({
group,
items: groupItems,
}));
};
export const CompletionMenu: React.FC<CompletionMenuProps> = ({
items,
onSelect,
@@ -24,9 +46,16 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
selectedIndex = 0,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState(selectedIndex);
// Mount state to drive a simple Tailwind transition (replaces CSS keyframes)
const [mounted, setMounted] = useState(false);
// Track if selection change was from keyboard (should scroll) vs mouse (should not scroll)
const isKeyboardNavigation = useRef(false);
// Group items for display
const groupedItems = useMemo(() => groupItems(items), [items]);
const hasGroups = groupedItems.some((g) => g.group !== null);
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
useEffect(() => setMounted(true), []);
@@ -45,10 +74,12 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
isKeyboardNavigation.current = true;
setSelected((prev) => Math.min(prev + 1, items.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
isKeyboardNavigation.current = true;
setSelected((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
@@ -75,11 +106,28 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
}, [items, selected, onSelect, onClose]);
useEffect(() => {
const selectedEl = containerRef.current?.querySelector(
// Only scroll into view for keyboard navigation, not mouse hover
if (!isKeyboardNavigation.current) {
return;
}
isKeyboardNavigation.current = false;
const selectedEl = listRef.current?.querySelector(
`[data-index="${selected}"]`,
);
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
if (selectedEl && listRef.current) {
// Use scrollIntoView only within the list container to avoid page scroll
const listRect = listRef.current.getBoundingClientRect();
const elRect = selectedEl.getBoundingClientRect();
// Check if element is outside the visible area of the list
if (elRect.top < listRect.top) {
// Element is above visible area, scroll up
selectedEl.scrollIntoView({ block: 'start', behavior: 'instant' });
} else if (elRect.bottom > listRect.bottom) {
// Element is below visible area, scroll down
selectedEl.scrollIntoView({ block: 'end', behavior: 'instant' });
}
}
}, [selected]);
@@ -87,6 +135,9 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
return null;
}
// Track global index for keyboard navigation
let globalIndex = 0;
return (
<div
ref={containerRef}
@@ -104,67 +155,83 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
{/* Optional top spacer for visual separation from the input */}
<div className="h-1" />
<div
ref={listRef}
className={[
// Semantic
'completion-menu-list',
// Scroll area
'flex max-h-[300px] flex-col overflow-y-auto',
// Spacing driven by theme vars
'p-[var(--app-list-padding)] pb-2 gap-[var(--app-list-gap)]',
'p-[var(--app-list-padding)] pb-2',
].join(' ')}
>
{title && (
{title && !hasGroups && (
<div className="completion-menu-section-label px-3 py-1 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em]">
{title}
</div>
)}
{items.map((item, index) => {
const isActive = index === selected;
return (
<div
key={item.id}
data-index={index}
role="menuitem"
onClick={() => onSelect(item)}
onMouseEnter={() => setSelected(index)}
className={[
// Semantic
'completion-menu-item',
// Hit area
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
'p-[var(--app-list-item-padding)]',
// Active background
isActive ? 'bg-[var(--app-list-active-background)]' : '',
].join(' ')}
>
<div className="completion-menu-item-row flex items-center justify-between gap-2">
{item.icon && (
<span className="completion-menu-item-icon inline-flex h-4 w-4 items-center justify-center text-[var(--vscode-symbolIcon-fileForeground,#cccccc)]">
{item.icon}
</span>
)}
<span
className={[
'completion-menu-item-label flex-1 truncate',
isActive
? 'text-[var(--app-list-active-foreground)]'
: 'text-[var(--app-primary-foreground)]',
].join(' ')}
>
{item.label}
</span>
{item.description && (
<span
className="completion-menu-item-desc max-w-[50%] truncate text-[0.9em] text-[var(--app-secondary-foreground)] opacity-70"
title={item.description}
>
{item.description}
</span>
)}
{groupedItems.map((group, groupIdx) => (
<div
key={group.group || `ungrouped-${groupIdx}`}
className="completion-menu-group"
>
{hasGroups && group.group && (
<div className="completion-menu-section-label px-3 py-1.5 text-[var(--app-secondary-foreground)] text-[0.8em] uppercase tracking-wider">
{group.group}
</div>
)}
<div className="flex flex-col gap-[var(--app-list-gap)]">
{group.items.map((item) => {
const currentIndex = globalIndex++;
const isActive = currentIndex === selected;
return (
<div
key={item.id}
data-index={currentIndex}
role="menuitem"
onClick={() => onSelect(item)}
onMouseEnter={() => setSelected(currentIndex)}
className={[
// Semantic
'completion-menu-item',
// Hit area
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
'p-[var(--app-list-item-padding)]',
// Active background
isActive ? 'bg-[var(--app-list-active-background)]' : '',
].join(' ')}
>
<div className="completion-menu-item-row flex items-center justify-between gap-2">
{item.icon && (
<span className="completion-menu-item-icon inline-flex h-4 w-4 items-center justify-center text-[var(--vscode-symbolIcon-fileForeground,#cccccc)]">
{item.icon}
</span>
)}
<span
className={[
'completion-menu-item-label flex-1 truncate',
isActive
? 'text-[var(--app-list-active-foreground)]'
: 'text-[var(--app-primary-foreground)]',
].join(' ')}
>
{item.label}
</span>
{item.description && (
<span
className="completion-menu-item-desc max-w-[50%] truncate text-[0.9em] text-[var(--app-secondary-foreground)] opacity-70"
title={item.description}
>
{item.description}
</span>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
))}
</div>
</div>
);

View File

@@ -18,9 +18,11 @@ import {
StopIcon,
} from '../icons/index.js';
import { CompletionMenu } from '../layout/CompletionMenu.js';
import { ModelSelector } from '../layout/ModelSelector.js';
import type { CompletionItem } from '../../../types/completionItemTypes.js';
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
import type { ModelInfo } from '../../../types/acpTypes.js';
import { ContextIndicator } from './ContextIndicator.js';
interface InputFormProps {
@@ -58,6 +60,12 @@ interface InputFormProps {
completionItems?: CompletionItem[];
onCompletionSelect?: (item: CompletionItem) => void;
onCompletionClose?: () => void;
// Model selector props
showModelSelector?: boolean;
availableModels?: ModelInfo[];
currentModelId?: string | null;
onSelectModel?: (modelId: string) => void;
onCloseModelSelector?: () => void;
}
// Get edit mode display info using helper function
@@ -118,6 +126,11 @@ export const InputForm: React.FC<InputFormProps> = ({
completionItems,
onCompletionSelect,
onCompletionClose,
showModelSelector,
availableModels,
currentModelId,
onSelectModel,
onCloseModelSelector,
}) => {
const editModeInfo = getEditModeInfo(editMode);
const composerDisabled = isStreaming || isWaitingForResponse;
@@ -174,6 +187,19 @@ export const InputForm: React.FC<InputFormProps> = ({
/>
)}
{showModelSelector &&
availableModels &&
onSelectModel &&
onCloseModelSelector && (
<ModelSelector
visible={showModelSelector}
models={availableModels}
currentModelId={currentModelId ?? null}
onSelectModel={onSelectModel}
onClose={onCloseModelSelector}
/>
)}
<div
ref={inputFieldRef}
contentEditable="plaintext-only"

View File

@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FC } from 'react';
import type { ModelInfo } from '../../../types/acpTypes.js';
import { PlanCompletedIcon } from '../icons/index.js';
interface ModelSelectorProps {
visible: boolean;
models: ModelInfo[];
currentModelId: string | null;
onSelectModel: (modelId: string) => void;
onClose: () => void;
}
export const ModelSelector: FC<ModelSelectorProps> = ({
visible,
models,
currentModelId,
onSelectModel,
onClose,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState(0);
const [mounted, setMounted] = useState(false);
// Reset selection when models change or when opened
useEffect(() => {
if (visible) {
// Find current model index or default to 0
const currentIndex = models.findIndex(
(m) => m.modelId === currentModelId,
);
setSelected(currentIndex >= 0 ? currentIndex : 0);
setMounted(true);
} else {
setMounted(false);
}
}, [visible, models, currentModelId]);
// Handle clicking outside to close and keyboard navigation
useEffect(() => {
if (!visible) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setSelected((prev) => Math.min(prev + 1, models.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
setSelected((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
event.preventDefault();
if (models[selected]) {
onSelectModel(models[selected].modelId);
onClose();
}
break;
case 'Escape':
event.preventDefault();
onClose();
break;
default:
break;
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [visible, models, selected, onSelectModel, onClose]);
// Scroll selected item into view
useEffect(() => {
const selectedEl = containerRef.current?.querySelector(
`[data-index="${selected}"]`,
);
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}, [selected]);
const handleModelSelect = useCallback(
(modelId: string) => {
onSelectModel(modelId);
onClose();
},
[onSelectModel, onClose],
);
if (!visible) {
return null;
}
return (
<div
ref={containerRef}
role="menu"
className={[
'model-selector',
// Positioning - bottom drawer style like CompletionMenu
'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden',
'rounded-large border bg-[var(--app-menu-background)]',
'border-[var(--app-input-border)] max-h-[50vh] z-[1000]',
// Mount animation
mounted ? 'animate-completion-menu-enter' : '',
].join(' ')}
>
{/* Header */}
<div className="px-3 py-1.5 text-[var(--app-secondary-foreground)] text-[0.8em] uppercase tracking-wider">
Select a model
</div>
{/* Model list */}
<div className="flex max-h-[300px] flex-col overflow-y-auto p-[var(--app-list-padding)] pb-2">
{models.length === 0 ? (
<div className="px-3 py-4 text-center text-[var(--app-secondary-foreground)] text-sm">
No models available. Check console for details.
</div>
) : (
models.map((model, index) => {
const isActive = index === selected;
const isCurrentModel = model.modelId === currentModelId;
return (
<div
key={model.modelId}
data-index={index}
role="menuitem"
onClick={() => handleModelSelect(model.modelId)}
onMouseEnter={() => setSelected(index)}
className={[
'model-selector-item',
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
'p-[var(--app-list-item-padding)]',
isActive ? 'bg-[var(--app-list-active-background)]' : '',
].join(' ')}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<span
className={[
'block truncate',
isActive
? 'text-[var(--app-list-active-foreground)]'
: 'text-[var(--app-primary-foreground)]',
].join(' ')}
>
{model.name}
</span>
{model.description && (
<span className="block truncate text-[0.85em] text-[var(--app-secondary-foreground)] opacity-70">
{model.description}
</span>
)}
</div>
{isCurrentModel && (
<span className="flex-shrink-0 text-[var(--app-list-active-foreground)]">
<PlanCompletedIcon size={16} />
</span>
)}
</div>
</div>
);
})
)}
</div>
</div>
);
};

View File

@@ -34,6 +34,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
'openNewChatTab',
// Settings-related messages
'setApprovalMode',
'setModel',
].includes(messageType);
}
@@ -125,6 +126,14 @@ export class SessionMessageHandler extends BaseMessageHandler {
);
break;
case 'setModel':
await this.handleSetModel(
message.data as {
modelId?: string;
},
);
break;
default:
console.warn(
'[SessionMessageHandler] Unknown message type:',
@@ -1034,4 +1043,24 @@ export class SessionMessageHandler extends BaseMessageHandler {
});
}
}
/**
* Set model via agent (ACP session/set_model)
*/
private async handleSetModel(data?: { modelId?: string }): Promise<void> {
try {
const modelId = data?.modelId;
if (!modelId) {
throw new Error('Model ID is required');
}
await this.agentManager.setModelFromUi(modelId);
// No explicit response needed; WebView listens for modelChanged
} catch (error) {
console.error('[SessionMessageHandler] Failed to set model:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to set model: ${error}` },
});
}
}
}

View File

@@ -17,7 +17,7 @@ import type {
} from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js';
import type { ModelInfo } from '../../types/acpTypes.js';
import type { ModelInfo, AvailableCommand } from '../../types/acpTypes.js';
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
'user_cancelled',
@@ -127,6 +127,10 @@ interface UseWebViewMessagesProps {
setUsageStats?: (stats: UsageStatsPayload | undefined) => void;
// Model info setter
setModelInfo?: (info: ModelInfo | null) => void;
// Available commands setter
setAvailableCommands?: (commands: AvailableCommand[]) => void;
// Available models setter
setAvailableModels?: (models: ModelInfo[]) => void;
}
/**
@@ -147,6 +151,8 @@ export const useWebViewMessages = ({
setIsAuthenticated,
setUsageStats,
setModelInfo,
setAvailableCommands,
setAvailableModels,
}: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host
const vscode = useVSCode();
@@ -166,6 +172,8 @@ export const useWebViewMessages = ({
setIsAuthenticated,
setUsageStats,
setModelInfo,
setAvailableCommands,
setAvailableModels,
});
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
@@ -213,6 +221,8 @@ export const useWebViewMessages = ({
setIsAuthenticated,
setUsageStats,
setModelInfo,
setAvailableCommands,
setAvailableModels,
};
});
@@ -245,6 +255,56 @@ export const useWebViewMessages = ({
break;
}
case 'modelChanged': {
try {
const model = message.data?.model as ModelInfo | undefined;
if (model) {
handlers.setModelInfo?.(model);
}
} catch (_error) {
// Ignore error when setting model
}
break;
}
case 'availableCommands': {
try {
const commands = message.data?.commands as
| AvailableCommand[]
| undefined;
if (commands) {
handlers.setAvailableCommands?.(commands);
}
} catch (_error) {
// Ignore error when setting available commands
}
break;
}
case 'availableModels': {
try {
const models = message.data?.models as ModelInfo[] | undefined;
console.log(
'[useWebViewMessages] availableModels message received:',
models,
);
if (models) {
handlers.setAvailableModels?.(models);
console.log(
'[useWebViewMessages] setAvailableModels called with:',
models,
);
}
} catch (_error) {
// Ignore error when setting available models
console.error(
'[useWebViewMessages] Error setting available models:',
_error,
);
}
break;
}
case 'usageStats': {
const stats = message.data as UsageStatsPayload | undefined;
handlers.setUsageStats?.(stats);