Merge branch 'main' into feat/skills

This commit is contained in:
tanzhenxin
2025-12-15 14:25:56 +08:00
103 changed files with 4035 additions and 2984 deletions

2
.vscode/launch.json vendored
View File

@@ -27,7 +27,7 @@
"outFiles": [
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
],
"preLaunchTask": "npm: build: vscode-ide-companion"
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
},
{
"name": "Attach",

16
.vscode/tasks.json vendored
View File

@@ -20,6 +20,22 @@
"problemMatcher": [],
"label": "npm: build: vscode-ide-companion",
"detail": "npm run build -w packages/vscode-ide-companion"
},
{
"label": "copy: bundled-cli (dev)",
"type": "shell",
"command": "node",
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
"problemMatcher": []
},
{
"label": "launch: vscode-ide-companion (copy+build)",
"dependsOrder": "sequence",
"dependsOn": [
"copy: bundled-cli (dev)",
"npm: build: vscode-ide-companion"
],
"problemMatcher": []
}
]
}

View File

@@ -10,19 +10,21 @@ The `/language` command allows you to customize the language settings for both t
To change the UI language of Qwen Code, use the `ui` subcommand:
```
/language ui [zh-CN|en-US]
/language ui [zh-CN|en-US|ru-RU]
```
### Available UI Languages
- **zh-CN**: Simplified Chinese (简体中文)
- **en-US**: English
- **ru-RU**: Russian (Русский)
### Examples
```
/language ui zh-CN # Set UI language to Simplified Chinese
/language ui en-US # Set UI language to English
/language ui ru-RU # Set UI language to Russian
```
### UI Language Subcommands
@@ -31,6 +33,7 @@ You can also use direct subcommands for convenience:
- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文`
- `/language ui en-US` or `/language ui en` or `/language ui english`
- `/language ui ru-RU` or `/language ui ru` or `/language ui русский`
## LLM Output Language Settings

60
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.4.1",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.4.1",
"version": "0.5.0",
"workspaces": [
"packages/*"
],
@@ -568,7 +568,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -592,7 +591,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2157,7 +2155,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -3671,7 +3668,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4142,7 +4138,6 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4153,7 +4148,6 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -4359,7 +4353,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5135,7 +5128,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5530,7 +5522,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -6865,6 +6858,7 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -7982,7 +7976,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8518,6 +8511,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -8579,6 +8573,7 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -8588,6 +8583,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8597,6 +8593,7 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -8763,6 +8760,7 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -8781,6 +8779,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8789,13 +8788,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -9909,7 +9910,6 @@
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0",
@@ -11864,6 +11864,7 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -13162,7 +13163,8 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -13821,7 +13823,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13832,7 +13833,6 @@
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -13866,7 +13866,6 @@
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -15932,7 +15931,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16112,8 +16110,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16121,7 +16118,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16316,7 +16312,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16623,6 +16618,7 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -16678,7 +16674,6 @@
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
@@ -16792,7 +16787,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16806,7 +16800,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17485,7 +17478,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17501,7 +17493,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.4.1",
"version": "0.5.0",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -17616,7 +17608,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.4.1",
"version": "0.5.0",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -17747,7 +17739,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17757,7 +17748,7 @@
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.5.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4"
@@ -18324,7 +18315,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -18746,7 +18736,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -19623,7 +19612,6 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1",
@@ -20189,7 +20177,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.4.1",
"version": "0.5.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -20201,7 +20189,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.4.1",
"version": "0.5.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.4.1",
"version": "0.5.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.4.1",
"version": "0.5.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

@@ -88,6 +88,16 @@ export class AgentSideConnection implements Client {
);
}
/**
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
*/
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
return await this.#connection.sendNotification(
schema.CLIENT_METHODS.authenticate_update,
params,
);
}
/**
* Request permission before running a tool
*
@@ -241,9 +251,11 @@ class Connection {
).toResult();
}
let errorName;
let details;
if (error instanceof Error) {
errorName = error.name;
details = error.message;
} else if (
typeof error === 'object' &&
@@ -254,6 +266,10 @@ class Connection {
details = error.message;
}
if (errorName === 'TokenManagerError') {
return RequestError.authRequired(details).toResult();
}
return RequestError.internalError(details).toResult();
}
}
@@ -357,6 +373,7 @@ export interface Client {
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;

View File

@@ -6,15 +6,19 @@
import type { ReadableStream, WritableStream } from 'node:stream/web';
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
import {
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
clearCachedCredentialFile,
QwenOAuth2Event,
qwenOAuth2Events,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
@@ -123,13 +127,33 @@ class GeminiAgent {
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
let authUri: string | undefined;
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
authUri = deviceAuth.verification_uri_complete;
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
void this.client.authenticateUpdate({ _meta: { authUri } });
};
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
}
await clearCachedCredentialFile();
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
try {
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
} finally {
// Ensure we don't leak listeners if auth fails early.
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
}
}
return;
}
async newSession({
@@ -268,14 +292,17 @@ class GeminiAgent {
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) {
throw acp.RequestError.authRequired();
throw acp.RequestError.authRequired('No Selected Type');
}
try {
await config.refreshAuth(selectedType);
// Use true for the second argument to ensure only cached credentials are used
await config.refreshAuth(selectedType, true);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
throw acp.RequestError.authRequired(
'Authentication failed: ' + (e as Error).message,
);
}
}

View File

@@ -20,6 +20,7 @@ export const AGENT_METHODS = {
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
};
@@ -57,8 +58,6 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
@@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({
methodId: z.string(),
});
export const authenticateResponseSchema = z.null();
export const authenticateUpdateSchema = z.object({
_meta: z.object({
authUri: z.string(),
}),
});
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
@@ -555,7 +560,6 @@ export const sessionUpdateSchema = z.union([
export const agentResponseSchema = z.union([
initializeResponseSchema,
authenticateResponseSchema,
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,

View File

@@ -138,6 +138,7 @@ export interface CliArgs {
coreTools: string[] | undefined;
excludeTools: string[] | undefined;
authType: string | undefined;
channel: string | undefined;
}
function normalizeOutputFormat(
@@ -297,6 +298,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -559,6 +565,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp'] && !result['channel']) {
(result as Record<string, unknown>)['channel'] = 'ACP';
}
return result as unknown as CliArgs;
}
@@ -983,6 +995,7 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
channel: argv.channel,
});
}

View File

@@ -191,8 +191,19 @@ const SETTINGS_SCHEMA = {
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
],
},
terminalBell: {
type: 'boolean',
label: 'Terminal Bell',
category: 'General',
requiresRestart: false,
default: true,
description:
'Play terminal bell sound when response completes or needs approval.',
showInDialog: true,
},
},
},
output: {

View File

@@ -485,6 +485,7 @@ describe('gemini.tsx main function kitty protocol', () => {
excludeTools: undefined,
authType: undefined,
maxSessionTurns: undefined,
channel: undefined,
});
await main();

View File

@@ -9,7 +9,7 @@ import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
// State
let currentLanguage: SupportedLanguage = 'en';
@@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en';
if (envLang?.startsWith('ru')) return 'ru';
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ru')) return 'ru';
} catch {
// Fallback to default
}

View File

@@ -867,6 +867,7 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!',
'To continue this session, run': 'To continue this session, run',
'Interaction Summary': 'Interaction Summary',
'Session ID:': 'Session ID:',
'Tool Calls:': 'Tool Calls:',

File diff suppressed because it is too large Load Diff

View File

@@ -820,6 +820,7 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!',
'To continue this session, run': '要继续此会话,请运行',
'Interaction Summary': '交互摘要',
'Session ID:': '会话 ID',
'Tool Calls:': '工具调用:',

View File

@@ -58,7 +58,6 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
vi.mock('../ui/commands/extensionsCommand.js', () => ({

View File

@@ -15,7 +15,6 @@ import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
@@ -63,7 +62,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
clearCommand,
compressCommand,
copyCommand,
corgiCommand,
docsCommand,
directoryCommand,
editorCommand,

View File

@@ -56,10 +56,10 @@ export const createMockCommandContext = (
pendingItem: null,
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
reloadCommands: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
session: {

View File

@@ -136,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { settings, config, initializationResult } = props;
const historyManager = useHistory();
useMemoryMonitor(historyManager);
const [corgiMode, setCorgiMode] = useState(false);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -485,7 +484,6 @@ export const AppContainer = (props: AppContainerProps) => {
}, 100);
},
setDebugMessage,
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
@@ -498,7 +496,6 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
setDebugMessage,
setCorgiMode,
dispatchExtensionStateUpdate,
openPermissionsDialog,
openApprovalModeDialog,
@@ -945,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFocused,
streamingState,
elapsedTime,
settings,
});
// Dialog close functionality
@@ -1218,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1309,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,

View File

@@ -1,34 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { corgiCommand } from './corgiCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('corgiCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
});
it('should call the toggleCorgiMode function on the UI context', async () => {
if (!corgiCommand.action) {
throw new Error('The corgi command must have an action.');
}
await corgiCommand.action(mockContext, '');
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
});
it('should have the correct name and description', () => {
expect(corgiCommand.name).toBe('corgi');
expect(corgiCommand.description).toBe('Toggles corgi mode.');
});
});

View File

@@ -1,17 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
hidden: true,
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
};

View File

@@ -0,0 +1,587 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
// Mock i18n module
vi.mock('../../i18n/index.js', () => ({
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
getCurrentLanguage: vi.fn().mockReturnValue('en'),
t: vi.fn((key: string) => key),
}));
// Mock settings module to avoid Storage side effect
vi.mock('../../config/settings.js', () => ({
SettingScope: {
User: 'user',
Workspace: 'workspace',
Default: 'default',
},
}));
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
default: {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
},
};
});
// Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'),
},
};
});
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { languageCommand } from './languageCommand.js';
describe('languageCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
},
settings: {
merged: {},
setValue: vi.fn(),
},
},
});
// Reset i18n mocks
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
vi.mocked(i18n.t).mockImplementation((key: string) => key);
// Reset fs mocks
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('command metadata', () => {
it('should have the correct name', () => {
expect(languageCommand.name).toBe('language');
});
it('should have a description', () => {
expect(languageCommand.description).toBeDefined();
expect(typeof languageCommand.description).toBe('string');
});
it('should be a built-in command', () => {
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should have subcommands', () => {
expect(languageCommand.subCommands).toBeDefined();
expect(languageCommand.subCommands?.length).toBe(2);
});
it('should have ui and output subcommands', () => {
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
expect(subCommandNames).toContain('ui');
expect(subCommandNames).toContain('output');
});
});
describe('main command action - no arguments', () => {
it('should show current language settings when no arguments provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
});
it('should show available subcommands in help', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language ui'),
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language output'),
});
});
it('should show LLM output language when set', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
);
// Make t() function handle interpolation for this test
vi.mocked(i18n.t).mockImplementation(
(key: string, params?: Record<string, string>) => {
if (params && key.includes('{{lang}}')) {
return key.replace('{{lang}}', params['lang'] || '');
}
return key;
},
);
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
// Verify it correctly parses "Chinese" from the template format
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Chinese'),
});
});
});
describe('main command action - config not available', () => {
it('should return error when config is null', async () => {
mockContext.services.config = null;
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Configuration not available'),
});
});
});
describe('/language ui subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language ui'),
});
});
it('should set English with "en"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "en-US"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en-US');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "english"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui english');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh-CN"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh-CN');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "chinese"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui chinese');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for invalid language', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui invalid');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid language'),
});
});
it('should persist setting to user scope', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'ui en');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.anything(), // SettingScope.User
'general.language',
'en',
);
});
});
describe('/language output subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language output'),
});
});
it('should create LLM output language rule file', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Chinese');
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
it('should include restart notice in success message', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Japanese');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('restart'),
});
});
it('should handle file write errors gracefully', async () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output German');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Failed to generate'),
});
});
});
describe('backward compatibility - direct language arguments', () => {
it('should set Chinese with direct "zh" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with direct "en" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for unknown direct argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'unknown');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid command'),
});
});
});
describe('ui subcommand object', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
it('should have correct metadata', () => {
expect(uiSubcommand).toBeDefined();
expect(uiSubcommand?.name).toBe('ui');
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
});
it('should have action that sets language', async () => {
if (!uiSubcommand?.action) {
throw new Error('UI subcommand must have an action.');
}
const result = await uiSubcommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
});
describe('output subcommand object', () => {
const outputSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'output',
);
it('should have correct metadata', () => {
expect(outputSubcommand).toBeDefined();
expect(outputSubcommand?.name).toBe('output');
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have action that generates rule file', async () => {
if (!outputSubcommand?.action) {
throw new Error('Output subcommand must have an action.');
}
// Ensure mocks are properly set for this test
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
const result = await outputSubcommand.action(mockContext, 'French');
expect(fs.writeFileSync).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
});
describe('nested ui language subcommands', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'zh-CN',
);
const enUSSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'en-US',
);
it('zh-CN should have aliases', () => {
expect(zhCNSubcommand?.altNames).toContain('zh');
expect(zhCNSubcommand?.altNames).toContain('chinese');
});
it('en-US should have aliases', () => {
expect(enUSSubcommand?.altNames).toContain('en');
expect(enUSSubcommand?.altNames).toContain('english');
});
it('zh-CN action should set Chinese', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('en-US action should set English', async () => {
if (!enUSSubcommand?.action) {
throw new Error('en-US subcommand must have an action.');
}
const result = await enUSSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, 'extra args');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('do not accept additional arguments'),
});
});
});
});

View File

@@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
// Extract language name from the first line
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
if (match) {
return match[1];
}
@@ -127,16 +128,17 @@ async function setUiLanguage(
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
lang: langDisplayNames[lang] || lang,
}),
};
}
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US]
// Handle /language ui [zh-CN|en-US|ru-RU]
if (parts.length === 1) {
// Show UI language subcommand help
return {
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
t(' - ru-RU: Russian'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
@@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
};
}
@@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
@@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = {
return setUiLanguage(context, 'en');
},
},
{
name: 'ru-RU',
altNames: ['ru', 'russian', 'русский'],
get description() {
return t('Set UI language to Russian (ru-RU)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
},
{

View File

@@ -64,8 +64,6 @@ export interface CommandContext {
* @param history The array of history items to load.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;

View File

@@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
},
branchName: 'main',
debugMessage: '',
corgiMode: false,
errorCount: 0,
nightly: false,
isTrustedFolder: true,
@@ -183,6 +182,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState, settings);
// Smoke check that the Footer renders when enabled.
expect(lastFrame()).toContain('Footer');
});
@@ -200,7 +200,6 @@ describe('Composer', () => {
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
corgiMode: true,
errorCount: 2,
sessionStats: {
sessionId: 'test-session',

View File

@@ -33,7 +33,6 @@ export const Footer: React.FC = () => {
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
@@ -45,7 +44,6 @@ export const Footer: React.FC = () => {
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@@ -153,16 +151,6 @@ export const Footer: React.FC = () => {
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>

View File

@@ -20,16 +20,21 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
const renderWithMockedStats = (
metrics: SessionMetrics,
sessionId: string = 'test-session-id-12345',
promptCount: number = 5,
) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId,
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
promptCount,
},
getPromptCount: () => 5,
getPromptCount: () => promptCount,
startNewPrompt: vi.fn(),
});
@@ -70,6 +75,38 @@ describe('<SessionSummaryDisplay />', () => {
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toContain('To continue this session, run');
expect(output).toContain('qwen --resume test-session-id-12345');
expect(output).toMatchSnapshot();
});
it('does not show resume message when there are no messages', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
// Pass promptCount = 0 to simulate no messages
const { lastFrame } = renderWithMockedStats(
metrics,
'test-session-id-12345',
0,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume');
});
});

View File

@@ -5,7 +5,10 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
interface SessionSummaryDisplayProps {
@@ -14,9 +17,28 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
);
}) => {
const { stats } = useSessionStats();
// Only show the resume message if there were messages in the session
const hasMessages = stats.promptCount > 0;
return (
<>
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
{hasMessages && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('To continue this session, run')}{' '}
<Text color={theme.text.accent}>
qwen --resume {stats.sessionId}
</Text>
</Text>
</Box>
)}
</>
);
};

View File

@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
context: {
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: true,
respectQwenIgnore: true,
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
loadMemoryFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: false,
respectQwenIgnore: false,
enableRecursiveFileSearch: false,
disableFuzzySearch: false,
},

View File

@@ -6,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ Agent powering down. Goodbye! │
│ │
│ Interaction Summary │
│ Session ID:
│ Session ID: test-session-id-12345
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ Code Changes: +42 -15 │
@@ -26,5 +26,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
To continue this session, run qwen --resume test-session-id-12345"
`;

View File

@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false* │
│ │
│ ▼ │
│ │
│ │
@@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title true* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips true* │
│ │
│ ▼ │
│ │
│ │

View File

@@ -54,7 +54,6 @@ export interface UIState {
qwenAuthState: QwenAuthState;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;

View File

@@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => {
openModelDialog: mockOpenModelDialog,
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
toggleCorgiMode: vi.fn(),
},
),
);
@@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openSettingsDialog
vi.fn(), // openModelSelectionDialog

View File

@@ -68,7 +68,6 @@ interface SlashCommandProcessorActions {
openApprovalModeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
openSubagentCreateDialog: () => void;
@@ -206,7 +205,6 @@ export const useSlashCommandProcessor = (
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
toggleCorgiMode: actions.toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,

View File

@@ -15,6 +15,23 @@ import {
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
useAttentionNotifications,
} from './useAttentionNotifications.js';
import type { LoadedSettings } from '../../config/settings.js';
const mockSettings: LoadedSettings = {
merged: {
general: {
terminalBell: true,
},
},
} as LoadedSettings;
const mockSettingsDisabled: LoadedSettings = {
merged: {
general: {
terminalBell: false,
},
},
} as LoadedSettings;
vi.mock('../../utils/attentionNotification.js', () => ({
notifyTerminalAttention: vi.fn(),
@@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
...props,
},
},
@@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: true },
);
});
@@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
settings: mockSettings,
},
});
@@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
{ enabled: true },
);
});
@@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
settings: mockSettings,
},
});
@@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: 5,
settings: mockSettings,
},
});
@@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).not.toHaveBeenCalled();
});
it('does not notify when terminalBell setting is disabled', () => {
const { rerender } = render({
settings: mockSettingsDisabled,
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettingsDisabled,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: false },
);
});
});

View File

@@ -10,6 +10,7 @@ import {
notifyTerminalAttention,
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions {
isFocused: boolean;
streamingState: StreamingState;
elapsedTime: number;
settings: LoadedSettings;
}
export const useAttentionNotifications = ({
isFocused,
streamingState,
elapsedTime,
settings,
}: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
@@ -33,14 +37,16 @@ export const useAttentionNotifications = ({
!isFocused &&
!awaitingNotificationSentRef.current
) {
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
enabled: terminalBellEnabled,
});
awaitingNotificationSentRef.current = true;
}
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
awaitingNotificationSentRef.current = false;
}
}, [isFocused, streamingState]);
}, [isFocused, streamingState, terminalBellEnabled]);
useEffect(() => {
if (streamingState === StreamingState.Responding) {
@@ -53,11 +59,13 @@ export const useAttentionNotifications = ({
respondingElapsedRef.current >=
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
if (wasLongTask && !isFocused) {
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
enabled: terminalBellEnabled,
});
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
return;
}
}, [streamingState, elapsedTime, isFocused]);
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
};

View File

@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
loadHistory: (_newHistory) => {},
pendingItem: null,
setPendingItem: (_item) => {},
toggleCorgiMode: () => {},
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},

View File

@@ -13,6 +13,7 @@ export enum AttentionNotificationReason {
export interface TerminalNotificationOptions {
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
enabled?: boolean;
}
const TERMINAL_BELL = '\u0007';
@@ -28,6 +29,11 @@ export function notifyTerminalAttention(
_reason: AttentionNotificationReason,
options: TerminalNotificationOptions = {},
): boolean {
// Check if terminal bell is enabled (default true for backwards compatibility)
if (options.enabled === false) {
return false;
}
const stream = options.stream ?? process.stdout;
if (!stream?.write || stream.isTTY === false) {
return false;

View File

@@ -38,7 +38,6 @@
"src/ui/commands/clearCommand.test.ts",
"src/ui/commands/compressCommand.test.ts",
"src/ui/commands/copyCommand.test.ts",
"src/ui/commands/corgiCommand.test.ts",
"src/ui/commands/docsCommand.test.ts",
"src/ui/commands/editorCommand.test.ts",
"src/ui/commands/extensionsCommand.test.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.4.1",
"version": "0.5.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -351,6 +351,7 @@ export interface ConfigParameters {
skipStartupContext?: boolean;
sdkMode?: boolean;
sessionSubagents?: SubagentConfig[];
channel?: string;
}
function normalizeConfigOutputFormat(
@@ -488,6 +489,7 @@ export class Config {
private readonly enableToolOutputTruncation: boolean;
private readonly eventEmitter?: EventEmitter;
private readonly useSmartEdit: boolean;
private readonly channel: string | undefined;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId ?? randomUUID();
@@ -601,6 +603,7 @@ export class Config {
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
this.useSmartEdit = params.useSmartEdit ?? false;
this.extensionManagement = params.extensionManagement ?? true;
this.channel = params.channel;
this.storage = new Storage(this.targetDir);
this.vlmSwitchMode = params.vlmSwitchMode;
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
@@ -1148,6 +1151,10 @@ export class Config {
return this.cliVersion;
}
getChannel(): string | undefined {
return this.channel;
}
/**
* Get the current FileSystemService
*/

View File

@@ -7,7 +7,13 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { OpenAIContentConverter } from './converter.js';
import type { StreamingToolCallParser } from './streamingToolCallParser.js';
import type { GenerateContentParameters, Content } from '@google/genai';
import {
Type,
type GenerateContentParameters,
type Content,
type Tool,
type CallableTool,
} from '@google/genai';
import type OpenAI from 'openai';
describe('OpenAIContentConverter', () => {
@@ -202,4 +208,338 @@ describe('OpenAIContentConverter', () => {
);
});
});
describe('convertGeminiToolsToOpenAI', () => {
it('should convert Gemini tools with parameters field', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: Type.OBJECT,
properties: {
location: { type: Type.STRING },
},
required: ['location'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
},
});
});
it('should convert MCP tools with parametersJsonSchema field', async () => {
// MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types)
const mcpTools = [
{
functionDeclarations: [
{
name: 'read_file',
description: 'Read a file from disk',
parametersJsonSchema: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'read_file',
description: 'Read a file from disk',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
});
});
it('should handle CallableTool by resolving tool function', async () => {
const callableTools = [
{
tool: async () => ({
functionDeclarations: [
{
name: 'dynamic_tool',
description: 'A dynamically resolved tool',
parameters: {
type: Type.OBJECT,
properties: {},
},
},
],
}),
},
] as CallableTool[];
const result = await converter.convertGeminiToolsToOpenAI(callableTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('dynamic_tool');
});
it('should skip functions without name or description', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'valid_tool',
description: 'A valid tool',
},
{
name: 'missing_description',
// no description
},
{
// no name
description: 'Missing name',
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('valid_tool');
});
it('should handle tools without functionDeclarations', async () => {
const emptyTools: Tool[] = [
{} as Tool,
{ functionDeclarations: [] },
];
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
expect(result).toHaveLength(0);
});
it('should handle functions without parameters', async () => {
const geminiTools: Tool[] = [
{
functionDeclarations: [
{
name: 'no_params_tool',
description: 'A tool without parameters',
},
],
},
];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.parameters).toBeUndefined();
});
it('should not mutate original parametersJsonSchema', async () => {
const originalSchema = {
type: 'object',
properties: { foo: { type: 'string' } },
};
const mcpTools: Tool[] = [
{
functionDeclarations: [
{
name: 'test_tool',
description: 'Test tool',
parametersJsonSchema: originalSchema,
},
],
} as Tool,
];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
// Verify the result is a copy, not the same reference
expect(result[0].function.parameters).not.toBe(originalSchema);
expect(result[0].function.parameters).toEqual(originalSchema);
});
});
describe('convertGeminiToolParametersToOpenAI', () => {
it('should convert type names to lowercase', () => {
const params = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
amount: { type: 'NUMBER' },
name: { type: 'STRING' },
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'object',
properties: {
count: { type: 'integer' },
amount: { type: 'number' },
name: { type: 'string' },
},
});
});
it('should convert string numeric constraints to numbers', () => {
const params = {
type: 'object',
properties: {
value: {
type: 'number',
minimum: '0',
maximum: '100',
multipleOf: '0.5',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['value']).toEqual({
type: 'number',
minimum: 0,
maximum: 100,
multipleOf: 0.5,
});
});
it('should convert string length constraints to integers', () => {
const params = {
type: 'object',
properties: {
text: {
type: 'string',
minLength: '1',
maxLength: '100',
},
items: {
type: 'array',
minItems: '0',
maxItems: '10',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['text']).toEqual({
type: 'string',
minLength: 1,
maxLength: 100,
});
expect(properties?.['items']).toEqual({
type: 'array',
minItems: 0,
maxItems: 10,
});
});
it('should handle nested objects', () => {
const params = {
type: 'object',
properties: {
nested: {
type: 'object',
properties: {
deep: {
type: 'INTEGER',
minimum: '0',
},
},
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
expect(nestedProperties?.['deep']).toEqual({
type: 'integer',
minimum: 0,
});
});
it('should handle arrays', () => {
const params = {
type: 'array',
items: {
type: 'INTEGER',
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'array',
items: {
type: 'integer',
},
});
});
it('should return undefined for null or non-object input', () => {
expect(
converter.convertGeminiToolParametersToOpenAI(
null as unknown as Record<string, unknown>,
),
).toBeNull();
expect(
converter.convertGeminiToolParametersToOpenAI(
undefined as unknown as Record<string, unknown>,
),
).toBeUndefined();
});
it('should not mutate the original parameters', () => {
const original = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
},
};
const originalCopy = JSON.parse(JSON.stringify(original));
converter.convertGeminiToolParametersToOpenAI(original);
expect(original).toEqual(originalCopy);
});
});
});

View File

@@ -193,13 +193,11 @@ export class OpenAIContentConverter {
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
if (func.parametersJsonSchema) {
// MCP tool format - use parametersJsonSchema directly
if (func.parametersJsonSchema) {
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
}
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
} else if (func.parameters) {
// Gemini tool format - convert parameters to OpenAI format
parameters = this.convertGeminiToolParametersToOpenAI(

View File

@@ -130,10 +130,13 @@ export class DashScopeOpenAICompatibleProvider
}
buildMetadata(userPromptId: string): DashScopeRequestMetadata {
const channel = this.cliConfig.getChannel?.();
return {
metadata: {
sessionId: this.cliConfig.getSessionId?.(),
promptId: userPromptId,
...(channel ? { channel } : {}),
},
};
}

View File

@@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = {
metadata: {
sessionId?: string;
promptId: string;
channel?: string;
};
};

View File

@@ -761,7 +761,6 @@ describe('getQwenOAuthClient', () => {
});
it('should load cached credentials if available', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
@@ -769,10 +768,6 @@ describe('getQwenOAuthClient', () => {
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to use cached credentials
const mockTokenManager = {
getValidCredentials: vi.fn().mockResolvedValue(mockCredentials),
@@ -792,18 +787,6 @@ describe('getQwenOAuthClient', () => {
});
it('should handle cached credentials refresh failure', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'expired-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to fail with a specific error
const mockTokenManager = {
getValidCredentials: vi
@@ -833,6 +816,35 @@ describe('getQwenOAuthClient', () => {
SharedTokenManager.getInstance = originalGetInstance;
});
it('should not start device flow when requireCachedCredentials is true', async () => {
// Make SharedTokenManager fail so we hit the fallback path
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No credentials')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
// If requireCachedCredentials is honored, device-flow network requests should not start
vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig, {
requireCachedCredentials: true,
}),
),
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('CredentialsClearRequiredError', () => {
@@ -1574,178 +1586,6 @@ describe('Credential Caching Functions', () => {
expect(updatedCredentials.access_token).toBe('new-token');
});
});
describe('loadCachedQwenCredentials', () => {
it('should load and validate cached credentials successfully', async () => {
const { promises: fs } = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials));
// Test through getQwenOAuthClient which calls loadCachedQwenCredentials
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Make SharedTokenManager fail to test the fallback
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock successful auth flow after cache load fails
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
expect(fs.readFile).toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle invalid cached credentials gracefully', async () => {
const { promises: fs } = await import('node:fs');
// Mock file read to return invalid JSON
vi.mocked(fs.readFile).mockResolvedValue('invalid-json');
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock auth flow
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-token',
refresh_token: 'new-refresh',
token_type: 'Bearer',
expires_in: 3600,
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle file access errors', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock device flow to fail quickly
const mockAuthResponse = {
ok: true,
json: async () => ({
error: 'invalid_request',
error_description: 'Invalid request parameters',
}),
};
global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response);
// Should proceed to device flow when cache loading fails
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
});
});
describe('Enhanced Error Handling and Edge Cases', () => {

View File

@@ -514,26 +514,14 @@ export async function getQwenOAuthClient(
}
}
// If shared manager fails, check if we have cached credentials for device flow
if (await loadCachedQwenCredentials(client)) {
// We have cached credentials but they might be expired
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
}
return client;
}
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
// interactive device authorization (unless explicitly forbidden above).
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -689,6 +677,19 @@ async function authWithQwenDeviceFlow(
// Cache the new tokens
await cacheQwenCredentials(credentials);
// IMPORTANT:
// SharedTokenManager maintains an in-memory cache and throttles file checks.
// If we only write the creds file here, a subsequent `getQwenOAuthClient()`
// call in the same process (within the throttle window) may not re-read the
// updated file and could incorrectly re-trigger device auth.
// Clearing the cache forces the next call to reload from disk.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// In unit tests we sometimes mock SharedTokenManager.getInstance() with a
// minimal stub; cache invalidation is best-effort and should not break auth.
}
// Emit auth progress success event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
@@ -847,27 +848,6 @@ async function authWithQwenDeviceFlow(
}
}
async function loadCachedQwenCredentials(
client: QwenOAuth2Client,
): Promise<boolean> {
try {
const keyFile = getQwenCachedCredentialPath();
const creds = await fs.readFile(keyFile, 'utf-8');
const credentials = JSON.parse(creds) as QwenCredentials;
client.setCredentials(credentials);
// Verify that the credentials are still valid
const { token } = await client.getAccessToken();
if (!token) {
return false;
}
return true;
} catch (_) {
return false;
}
}
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
try {
@@ -913,6 +893,14 @@ export async function clearQwenCredentials(): Promise<void> {
}
// Log other errors but don't throw - clearing credentials should be non-critical
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
} finally {
// Also clear SharedTokenManager in-memory cache to prevent stale credentials
// from being reused within the same process after the file is removed.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// Best-effort; don't fail credential clearing if SharedTokenManager is mocked.
}
}
}

View File

@@ -250,6 +250,9 @@ export class QwenLogger {
authType === AuthType.USE_OPENAI
? this.config?.getContentGeneratorConfig().baseUrl || ''
: '',
...(this.config?.getChannel?.()
? { channel: this.config.getChannel() }
: {}),
},
_v: `qwen-code@${version}`,
} as RumPayload;

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.1.0",
"version": "0.5.0",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -139,6 +139,7 @@ export class ProcessTransport implements Transport {
'stream-json',
'--output-format',
'stream-json',
'--channel=SDK',
];
if (this.options.model) {

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -1,5 +1,6 @@
**
!dist/
!dist/**
../
../../
!LICENSE

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.4.1",
"version": "0.5.0",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {
@@ -113,7 +113,7 @@
"main": "./dist/extension.cjs",
"type": "module",
"scripts": {
"prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod",
"prepackage": "node ./scripts/prepackage.js",
"build": "npm run build:dev",
"build:dev": "npm run check-types && npm run lint && node esbuild.js",
"build:prod": "node esbuild.js --production",

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Copy the already-built root dist/ folder into the extension dist/qwen-cli/.
*
* Assumes repoRoot/dist already exists (e.g. produced by `npm run bundle` and
* optionally `npm run prepare:package`).
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const rootDistDir = path.join(repoRoot, 'dist');
const extensionDistDir = path.join(extensionRoot, 'dist');
const bundledCliDir = path.join(extensionDistDir, 'qwen-cli');
async function main() {
const cliJs = path.join(rootDistDir, 'cli.js');
const vendorDir = path.join(rootDistDir, 'vendor');
if (!existsSync(cliJs) || !existsSync(vendorDir)) {
throw new Error(
`[copy-bundled-cli] Missing root dist artifacts. Expected:\n- ${cliJs}\n- ${vendorDir}\n\nRun root "npm run bundle" first.`,
);
}
await fs.mkdir(extensionDistDir, { recursive: true });
const existingNodeModules = path.join(bundledCliDir, 'node_modules');
const tmpNodeModules = path.join(
extensionDistDir,
'qwen-cli.node_modules.tmp',
);
const keepNodeModules = existsSync(existingNodeModules);
// Preserve destination node_modules if it exists (e.g. after packaging install).
if (keepNodeModules) {
await fs.rm(tmpNodeModules, { recursive: true, force: true });
await fs.rename(existingNodeModules, tmpNodeModules);
}
await fs.rm(bundledCliDir, { recursive: true, force: true });
await fs.mkdir(bundledCliDir, { recursive: true });
await fs.cp(rootDistDir, bundledCliDir, { recursive: true });
if (keepNodeModules) {
await fs.rename(tmpNodeModules, existingNodeModules);
}
console.log(`[copy-bundled-cli] Copied ${rootDistDir} -> ${bundledCliDir}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* VS Code extension packaging orchestration.
*
* We bundle the CLI into the extension so users don't need a global install.
* To match the published CLI layout, we need to:
* - build root bundle (dist/cli.js + vendor/ + sandbox profiles)
* - run root prepare:package (dist/package.json + locales + README/LICENSE)
* - install production deps into root dist/ (dist/node_modules) so runtime deps
* like optional node-pty are present inside the VSIX payload.
*
* Then we generate notices and build the extension.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const bundledCliDir = path.join(extensionRoot, 'dist', 'qwen-cli');
function npmBin() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: process.platform === 'win32' ? true : false,
...opts,
});
if (res.error) {
throw res.error;
}
if (typeof res.status === 'number' && res.status !== 0) {
throw new Error(
`Command failed (${res.status}): ${cmd} ${args.map((a) => JSON.stringify(a)).join(' ')}`,
);
}
}
function main() {
const npm = npmBin();
console.log('[prepackage] Bundling root CLI...');
run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot });
console.log('[prepackage] Preparing root dist/ package metadata...');
run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot });
console.log('[prepackage] Generating notices...');
run(npm, ['run', 'generate:notices'], { cwd: extensionRoot });
console.log('[prepackage] Typechecking...');
run(npm, ['run', 'check-types'], { cwd: extensionRoot });
console.log('[prepackage] Linting...');
run(npm, ['run', 'lint'], { cwd: extensionRoot });
console.log('[prepackage] Building extension (production)...');
run(npm, ['run', 'build:prod'], { cwd: extensionRoot });
console.log('[prepackage] Copying bundled CLI dist/ into extension...');
run(
'node',
[`${path.join(extensionRoot, 'scripts', 'copy-bundled-cli.js')}`],
{
cwd: extensionRoot,
},
);
console.log(
'[prepackage] Installing production deps into extension dist/qwen-cli...',
);
run(
npm,
[
'--prefix',
bundledCliDir,
'install',
'--omit=dev',
'--no-audit',
'--no-fund',
],
{ cwd: bundledCliDir },
);
}
main();

View File

@@ -1,58 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { CliFeatureFlags, CliVersionInfo } from './cliVersionManager.js';
export class CliContextManager {
private static instance: CliContextManager;
private currentVersionInfo: CliVersionInfo | null = null;
private constructor() {}
/**
* Get singleton instance
*/
static getInstance(): CliContextManager {
if (!CliContextManager.instance) {
CliContextManager.instance = new CliContextManager();
}
return CliContextManager.instance;
}
/**
* Set current CLI version information
*
* @param versionInfo - CLI version information
*/
setCurrentVersionInfo(versionInfo: CliVersionInfo): void {
this.currentVersionInfo = versionInfo;
}
/**
* Get current CLI feature flags
*
* @returns Current CLI feature flags or default flags if not set
*/
getCurrentFeatures(): CliFeatureFlags {
if (this.currentVersionInfo) {
return this.currentVersionInfo.features;
}
// Return default feature flags (all disabled)
return {
supportsSessionList: false,
supportsSessionLoad: false,
};
}
supportsSessionList(): boolean {
return this.getCurrentFeatures().supportsSessionList;
}
supportsSessionLoad(): boolean {
return this.getCurrentFeatures().supportsSessionLoad;
}
}

View File

@@ -1,215 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export interface CliDetectionResult {
isInstalled: boolean;
cliPath?: string;
version?: string;
error?: string;
}
/**
* Detects if Qwen Code CLI is installed and accessible
*/
export class CliDetector {
private static cachedResult: CliDetectionResult | null = null;
private static lastCheckTime: number = 0;
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
/**
* Checks if the Qwen Code CLI is installed
* @param forceRefresh - Force a new check, ignoring cache
* @returns Detection result with installation status and details
*/
static async detectQwenCli(
forceRefresh = false,
): Promise<CliDetectionResult> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedResult &&
now - this.lastCheckTime < this.CACHE_DURATION_MS
) {
console.log('[CliDetector] Returning cached result');
return this.cachedResult;
}
console.log(
'[CliDetector] Starting CLI detection, current PATH:',
process.env.PATH,
);
try {
const isWindows = process.platform === 'win32';
const whichCommand = isWindows ? 'where' : 'which';
// Check if qwen command exists
try {
// Use NVM environment for consistent detection
// Fallback chain: default alias -> node alias -> current version
const detectionCommand =
process.platform === 'win32'
? `${whichCommand} qwen`
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); which qwen';
console.log(
'[CliDetector] Detecting CLI with command:',
detectionCommand,
);
const { stdout } = await execAsync(detectionCommand, {
timeout: 5000,
shell: '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual path
const lines = stdout
.trim()
.split('\n')
.filter((line) => line.trim());
const cliPath = lines[lines.length - 1];
console.log('[CliDetector] Found CLI at:', cliPath);
// Try to get version
let version: string | undefined;
try {
// Use NVM environment for version check
// Fallback chain: default alias -> node alias -> current version
// Also ensure we use the correct Node.js version that matches the CLI installation
const versionCommand =
process.platform === 'win32'
? 'qwen --version'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); qwen --version';
console.log(
'[CliDetector] Getting version with command:',
versionCommand,
);
const { stdout: versionOutput } = await execAsync(versionCommand, {
timeout: 5000,
shell: '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual version
const versionLines = versionOutput
.trim()
.split('\n')
.filter((line) => line.trim());
version = versionLines[versionLines.length - 1];
console.log('[CliDetector] CLI version:', version);
} catch (versionError) {
console.log('[CliDetector] Failed to get CLI version:', versionError);
// Version check failed, but CLI is installed
}
this.cachedResult = {
isInstalled: true,
cliPath,
version,
};
this.lastCheckTime = now;
return this.cachedResult;
} catch (detectionError) {
console.log('[CliDetector] CLI not found, error:', detectionError);
// CLI not found
let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`;
// Provide specific guidance for permission errors
if (detectionError instanceof Error) {
const errorMessage = detectionError.message;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
error += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
}
this.cachedResult = {
isInstalled: false,
error,
};
this.lastCheckTime = now;
return this.cachedResult;
}
} catch (error) {
console.log('[CliDetector] General detection error:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
let userFriendlyError = `Failed to detect Qwen Code CLI: ${errorMessage}`;
// Provide specific guidance for permission errors
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
this.cachedResult = {
isInstalled: false,
error: userFriendlyError,
};
this.lastCheckTime = now;
return this.cachedResult;
}
}
/**
* Clears the cached detection result
*/
static clearCache(): void {
this.cachedResult = null;
this.lastCheckTime = 0;
}
/**
* Gets installation instructions based on the platform
*/
static getInstallationInstructions(): {
title: string;
steps: string[];
documentationUrl: string;
} {
return {
title: 'Qwen Code CLI is not installed',
steps: [
'Install via npm:',
' npm install -g @qwen-code/qwen-code@latest',
'',
'If you are using nvm (automatically handled by the plugin):',
' The plugin will automatically use your default nvm version',
'',
'Or install from source:',
' git clone https://github.com/QwenLM/qwen-code.git',
' cd qwen-code',
' npm install',
' npm install -g .',
'',
'After installation, reload VS Code or restart the extension.',
],
documentationUrl: 'https://github.com/QwenLM/qwen-code#installation',
};
}
}

View File

@@ -1,225 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { CliDetector } from './cliDetector.js';
/**
* CLI Detection and Installation Handler
* Responsible for detecting, installing, and prompting for Qwen CLI
*/
export class CliInstaller {
/**
* Check CLI installation status and send results to WebView
* @param sendToWebView Callback function to send messages to WebView
*/
static async checkInstallation(
sendToWebView: (message: unknown) => void,
): Promise<void> {
try {
const result = await CliDetector.detectQwenCli();
sendToWebView({
type: 'cliDetectionResult',
data: {
isInstalled: result.isInstalled,
cliPath: result.cliPath,
version: result.version,
error: result.error,
installInstructions: result.isInstalled
? undefined
: CliDetector.getInstallationInstructions(),
},
});
if (!result.isInstalled) {
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
} else {
console.log(
'[CliInstaller] Qwen CLI detected:',
result.cliPath,
result.version,
);
}
} catch (error) {
console.error('[CliInstaller] CLI detection error:', error);
}
}
/**
* Prompt user to install CLI
* Display warning message with installation options
*/
static async promptInstallation(): Promise<void> {
const selection = await vscode.window.showWarningMessage(
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
'Install Now',
'View Documentation',
'Remind Me Later',
);
if (selection === 'Install Now') {
await this.install();
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
);
}
}
/**
* Install Qwen CLI
* Install global CLI package via npm
*/
static async install(): Promise<void> {
try {
// Show progress notification
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Installing Qwen Code CLI',
cancellable: false,
},
async (progress) => {
progress.report({
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
});
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
try {
// Use NVM environment to ensure we get the same Node.js version
// as when they run 'node -v' in terminal
// Fallback chain: default alias -> node alias -> current version
const installCommand =
process.platform === 'win32'
? 'npm install -g @qwen-code/qwen-code@latest'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); npm install -g @qwen-code/qwen-code@latest';
console.log(
'[CliInstaller] Installing with command:',
installCommand,
);
console.log(
'[CliInstaller] Current process PATH:',
process.env['PATH'],
);
// Also log Node.js version being used by VS Code
console.log(
'[CliInstaller] VS Code Node.js version:',
process.version,
);
console.log(
'[CliInstaller] VS Code Node.js execPath:',
process.execPath,
);
const { stdout, stderr } = await execAsync(
installCommand,
{
timeout: 120000,
shell: '/bin/bash',
}, // 2 minutes timeout
);
console.log('[CliInstaller] Installation output:', stdout);
if (stderr) {
console.warn('[CliInstaller] Installation stderr:', stderr);
}
// Clear cache and recheck
CliDetector.clearCache();
const detection = await CliDetector.detectQwenCli();
if (detection.isInstalled) {
vscode.window
.showInformationMessage(
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
'Reload Window',
)
.then((selection) => {
if (selection === 'Reload Window') {
vscode.commands.executeCommand(
'workbench.action.reloadWindow',
);
}
});
} else {
throw new Error(
'Installation completed but CLI still not detected',
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error('[CliInstaller] Installation failed:', errorMessage);
console.error('[CliInstaller] Error stack:', error);
// Provide specific guidance for permission errors
let userFriendlyMessage = `Failed to install Qwen Code CLI: ${errorMessage}`;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyMessage += `\n\nThis is likely due to permission issues. Possible solutions:
\n1. Reinstall without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. Fix npm permissions: sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Configure npm to use a different directory: npm config set prefix ~/.npm-global`;
}
vscode.window
.showErrorMessage(
userFriendlyMessage,
'Try Manual Installation',
'View Documentation',
)
.then((selection) => {
if (selection === 'Try Manual Installation') {
const terminal = vscode.window.createTerminal(
'Qwen Code Installation',
);
terminal.show();
// Provide different installation commands based on error type
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
terminal.sendText('# Try installing without sudo:');
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
terminal.sendText('');
terminal.sendText('# Or fix npm permissions:');
terminal.sendText(
'sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}',
);
} else {
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
}
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse(
'https://github.com/QwenLM/qwen-code#installation',
),
);
}
});
}
},
);
} catch (error) {
console.error('[CliInstaller] Install CLI error:', error);
}
}
}

View File

@@ -1,128 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { statSync } from 'fs';
export interface CliPathDetectionResult {
path: string | null;
error?: string;
}
/**
* Determine the correct Node.js executable path for a given CLI installation
* Handles various Node.js version managers (nvm, n, manual installations)
*
* @param cliPath - Path to the CLI executable
* @returns Path to the Node.js executable, or null if not found
*/
export function determineNodePathForCli(
cliPath: string,
): CliPathDetectionResult {
// Common patterns for Node.js installations
const nodePathPatterns = [
// NVM pattern: /Users/user/.nvm/versions/node/vXX.XX.X/bin/qwen -> /Users/user/.nvm/versions/node/vXX.XX.X/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// N pattern: /Users/user/n/bin/qwen -> /Users/user/n/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// Manual installation pattern: /usr/local/bin/qwen -> /usr/local/bin/node
cliPath.replace(/\/qwen$/, '/node'),
// Alternative pattern: /opt/nodejs/bin/qwen -> /opt/nodejs/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
];
// Check each pattern
for (const nodePath of nodePathPatterns) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
// Verify it's executable
if (stats.mode & 0o111) {
console.log(`[CLI] Found Node.js executable for CLI at: ${nodePath}`);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue to next pattern
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
// Try to find node in the same directory as the CLI
const cliDir = cliPath.substring(0, cliPath.lastIndexOf('/'));
const potentialNodePaths = [`${cliDir}/node`, `${cliDir}/bin/node`];
for (const nodePath of potentialNodePaths) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
if (stats.mode & 0o111) {
console.log(
`[CLI] Found Node.js executable in CLI directory at: ${nodePath}`,
);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
console.log(`[CLI] Could not determine Node.js path for CLI: ${cliPath}`);
return {
path: null,
error: `Could not find Node.js executable for CLI at ${cliPath}. Please verify the CLI installation.`,
};
}

View File

@@ -1,191 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import semver from 'semver';
import { CliDetector, type CliDetectionResult } from './cliDetector.js';
export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.4.0';
export interface CliFeatureFlags {
supportsSessionList: boolean;
supportsSessionLoad: boolean;
}
export interface CliVersionInfo {
version: string | undefined;
isSupported: boolean;
features: CliFeatureFlags;
detectionResult: CliDetectionResult;
}
/**
* CLI Version Manager
*
* Manages CLI version detection and feature availability based on version
*/
export class CliVersionManager {
private static instance: CliVersionManager;
private cachedVersionInfo: CliVersionInfo | null = null;
private lastCheckTime: number = 0;
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
private constructor() {}
/**
* Get singleton instance
*/
static getInstance(): CliVersionManager {
if (!CliVersionManager.instance) {
CliVersionManager.instance = new CliVersionManager();
}
return CliVersionManager.instance;
}
/**
* Check if CLI version meets minimum requirements
*
* @param version - Version string to check
* @param minVersion - Minimum required version
* @returns Whether version meets requirements
*/
private isVersionSupported(
version: string | undefined,
minVersion: string,
): boolean {
if (!version) {
return false;
}
// Use semver for robust comparison (handles v-prefix, pre-release, etc.)
const v = semver.valid(version) ?? semver.coerce(version)?.version ?? null;
const min =
semver.valid(minVersion) ?? semver.coerce(minVersion)?.version ?? null;
if (!v || !min) {
console.warn(
`[CliVersionManager] Invalid semver: version=${version}, min=${minVersion}`,
);
return false;
}
console.log(`[CliVersionManager] Version ${v} meets requirements: ${min}`);
return semver.gte(v, min);
}
/**
* Get feature flags based on CLI version
*
* @param version - CLI version string
* @returns Feature flags
*/
private getFeatureFlags(version: string | undefined): CliFeatureFlags {
const isSupportedVersion = this.isVersionSupported(
version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
);
return {
supportsSessionList: isSupportedVersion,
supportsSessionLoad: isSupportedVersion,
};
}
/**
* Detect CLI version and features
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns CLI version information
*/
async detectCliVersion(forceRefresh = false): Promise<CliVersionInfo> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedVersionInfo &&
now - this.lastCheckTime < CliVersionManager.CACHE_DURATION_MS
) {
console.log('[CliVersionManager] Returning cached version info');
return this.cachedVersionInfo;
}
console.log('[CliVersionManager] Detecting CLI version...');
try {
// Detect CLI installation
const detectionResult = await CliDetector.detectQwenCli(forceRefresh);
const versionInfo: CliVersionInfo = {
version: detectionResult.version,
isSupported: this.isVersionSupported(
detectionResult.version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
),
features: this.getFeatureFlags(detectionResult.version),
detectionResult,
};
// Cache the result
this.cachedVersionInfo = versionInfo;
this.lastCheckTime = now;
console.log(
'[CliVersionManager] CLI version detection result:',
versionInfo,
);
return versionInfo;
} catch (error) {
console.error('[CliVersionManager] Failed to detect CLI version:', error);
// Return fallback result
const fallbackResult: CliVersionInfo = {
version: undefined,
isSupported: false,
features: {
supportsSessionList: false,
supportsSessionLoad: false,
},
detectionResult: {
isInstalled: false,
error: error instanceof Error ? error.message : String(error),
},
};
return fallbackResult;
}
}
/**
* Clear cached version information
*/
clearCache(): void {
this.cachedVersionInfo = null;
this.lastCheckTime = 0;
CliDetector.clearCache();
}
/**
* Check if CLI supports session/list method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/list is supported
*/
async supportsSessionList(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionList;
}
/**
* Check if CLI supports session/load method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/load is supported
*/
async supportsSessionLoad(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionLoad;
}
}

View File

@@ -19,6 +19,7 @@ export const AGENT_METHODS = {
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;

View File

@@ -292,7 +292,14 @@ export async function activate(context: vscode.ExtensionContext) {
}
if (selectedFolder) {
const qwenCmd = 'qwen';
const cliEntry = vscode.Uri.joinPath(
context.extensionUri,
'dist',
'qwen-cli',
'cli.js',
).fsPath;
const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`;
const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`;
const terminal = vscode.window.createTerminal({
name: `Qwen Code (${selectedFolder.name})`,
cwd: selectedFolder.uri.fsPath,

View File

@@ -164,6 +164,7 @@ export class IDEServer {
const allowedHosts = [
`localhost:${this.port}`,
`127.0.0.1:${this.port}`,
`host.docker.internal:${this.port}`, // Add Docker support
];
if (!allowedHosts.includes(host)) {
return res.status(403).json({ error: 'Invalid Host header' });

View File

@@ -10,8 +10,9 @@ import type {
AcpPermissionRequest,
AcpResponse,
AcpSessionUpdate,
ApprovalModeValue,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { ChildProcess, SpawnOptions } from 'child_process';
import { spawn } from 'child_process';
import type {
@@ -20,7 +21,7 @@ import type {
} from '../types/connectionTypes.js';
import { AcpMessageHandler } from './acpMessageHandler.js';
import { AcpSessionManager } from './acpSessionManager.js';
import { determineNodePathForCli } from '../cli/cliPathDetector.js';
import * as fs from 'node:fs';
/**
* ACP Connection Handler for VSCode Extension
@@ -42,6 +43,8 @@ export class AcpConnection {
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}> = () => Promise.resolve({ optionId: 'allow' });
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void =
() => {};
onEndTurn: () => void = () => {};
// Called after successful initialize() with the initialize result
onInitialized: (init: unknown) => void = () => {};
@@ -54,12 +57,12 @@ export class AcpConnection {
/**
* Connect to Qwen ACP
*
* @param cliPath - CLI path
* @param cliEntryPath - Path to the bundled CLI entrypoint (cli.js)
* @param workingDir - Working directory
* @param extraArgs - Extra command line arguments
*/
async connect(
cliPath: string,
cliEntryPath: string,
workingDir: string = process.cwd(),
extraArgs: string[] = [],
): Promise<void> {
@@ -69,7 +72,6 @@ export class AcpConnection {
this.workingDir = workingDir;
const isWindows = process.platform === 'win32';
const env = { ...process.env };
// If proxy is configured in extraArgs, also set it as environment variable
@@ -88,38 +90,20 @@ export class AcpConnection {
env['https_proxy'] = proxyUrl;
}
let spawnCommand: string;
let spawnArgs: string[];
// Always run the bundled CLI using the VS Code extension host's Node runtime.
// This avoids PATH/NVM/global install problems and ensures deterministic behavior.
const spawnCommand: string = process.execPath;
const spawnArgs: string[] = [
cliEntryPath,
'--experimental-acp',
'--channel=VSCode',
...extraArgs,
];
if (cliPath.startsWith('npx ')) {
const parts = cliPath.split(' ');
spawnCommand = isWindows ? 'npx.cmd' : 'npx';
spawnArgs = [...parts.slice(1), '--experimental-acp', ...extraArgs];
} else {
// For qwen CLI, ensure we use the correct Node.js version
// Handle various Node.js version managers (nvm, n, manual installations)
if (cliPath.includes('/qwen') && !isWindows) {
// Try to determine the correct node executable for this qwen installation
const nodePathResult = determineNodePathForCli(cliPath);
if (nodePathResult.path) {
spawnCommand = nodePathResult.path;
spawnArgs = [cliPath, '--experimental-acp', ...extraArgs];
} else {
// Fallback to direct execution
spawnCommand = cliPath;
spawnArgs = ['--experimental-acp', ...extraArgs];
// Log any error for debugging
if (nodePathResult.error) {
console.warn(
`[ACP] Node.js path detection warning: ${nodePathResult.error}`,
);
}
}
} else {
spawnCommand = cliPath;
spawnArgs = ['--experimental-acp', ...extraArgs];
}
if (!fs.existsSync(cliEntryPath)) {
throw new Error(
`Bundled Qwen CLI entry not found at ${cliEntryPath}. The extension may not have been packaged correctly.`,
);
}
console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' '));
@@ -128,7 +112,8 @@ export class AcpConnection {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env,
shell: isWindows,
// We spawn node directly; no shell needed (and shell quoting can break paths).
shell: false,
};
this.child = spawn(spawnCommand, spawnArgs, options);
@@ -225,6 +210,7 @@ export class AcpConnection {
const callbacks: AcpConnectionCallbacks = {
onSessionUpdate: this.onSessionUpdate,
onPermissionRequest: this.onPermissionRequest,
onAuthenticateUpdate: this.onAuthenticateUpdate,
onEndTurn: this.onEndTurn,
};

View File

@@ -17,6 +17,7 @@ import type {
AcpResponse,
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
import { CLIENT_METHODS } from '../constants/acpSchema.js';
import type {
@@ -110,13 +111,20 @@ export class AcpMessageHandler {
// JSON.stringify(message.result).substring(0, 200),
message.result,
);
if (
message.result &&
typeof message.result === 'object' &&
'stopReason' in message.result &&
message.result.stopReason === 'end_turn'
) {
callbacks.onEndTurn();
if (message.result && typeof message.result === 'object') {
const stopReasonValue =
(message.result as { stopReason?: unknown }).stopReason ??
(message.result as { stop_reason?: unknown }).stop_reason;
if (typeof stopReasonValue === 'string') {
callbacks.onEndTurn(stopReasonValue);
} else if (
'stopReason' in message.result ||
'stop_reason' in message.result
) {
// stop_reason present but not a string (e.g., null) -> still emit
callbacks.onEndTurn();
}
}
resolve(message.result);
} else if ('error' in message) {
@@ -161,6 +169,15 @@ export class AcpMessageHandler {
);
callbacks.onSessionUpdate(params as AcpSessionUpdate);
break;
case CLIENT_METHODS.authenticate_update:
console.log(
'[ACP] >>> Processing authenticate_update:',
JSON.stringify(params).substring(0, 300),
);
callbacks.onAuthenticateUpdate(
params as AuthenticateUpdateNotification,
);
break;
case CLIENT_METHODS.session_request_permission:
result = await this.handlePermissionRequest(
params as AcpPermissionRequest,

View File

@@ -14,8 +14,8 @@ import type {
AcpRequest,
AcpNotification,
AcpResponse,
ApprovalModeValue,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { AGENT_METHODS } from '../constants/acpSchema.js';
import type { PendingRequest } from '../types/connectionTypes.js';
import type { ChildProcess } from 'child_process';
@@ -54,8 +54,14 @@ export class AcpSessionManager {
};
return new Promise((resolve, reject) => {
const timeoutDuration =
method === AGENT_METHODS.session_prompt ? 120000 : 60000;
// different timeout durations based on methods
let timeoutDuration = 60000; // default 60 seconds
if (
method === AGENT_METHODS.session_prompt ||
method === AGENT_METHODS.initialize
) {
timeoutDuration = 120000; // 2min for session_prompt and initialize
}
const timeoutId = setTimeout(() => {
pendingRequests.delete(id);
@@ -163,7 +169,7 @@ export class AcpSessionManager {
pendingRequests,
nextRequestId,
);
console.log('[ACP] Authenticate successful');
console.log('[ACP] Authenticate successful', response);
return response;
}

View File

@@ -1,215 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type * as vscode from 'vscode';
interface AuthState {
isAuthenticated: boolean;
authMethod: string;
timestamp: number;
workingDir?: string;
}
/**
* Manages authentication state caching to avoid repeated logins
*/
export class AuthStateManager {
private static instance: AuthStateManager | null = null;
private static context: vscode.ExtensionContext | null = null;
private static readonly AUTH_STATE_KEY = 'qwen.authState';
private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
private constructor() {}
/**
* Get singleton instance of AuthStateManager
*/
static getInstance(context?: vscode.ExtensionContext): AuthStateManager {
if (!AuthStateManager.instance) {
AuthStateManager.instance = new AuthStateManager();
}
// If a context is provided, update the static context
if (context) {
AuthStateManager.context = context;
}
return AuthStateManager.instance;
}
/**
* Check if there's a valid cached authentication
*/
async hasValidAuth(workingDir: string, authMethod: string): Promise<boolean> {
const state = await this.getAuthState();
if (!state) {
console.log('[AuthStateManager] No cached auth state found');
return false;
}
console.log('[AuthStateManager] Found cached auth state:', {
workingDir: state.workingDir,
authMethod: state.authMethod,
timestamp: new Date(state.timestamp).toISOString(),
isAuthenticated: state.isAuthenticated,
});
console.log('[AuthStateManager] Checking against:', {
workingDir,
authMethod,
});
// Check if auth is still valid (within cache duration)
const now = Date.now();
const isExpired =
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
if (isExpired) {
console.log('[AuthStateManager] Cached auth expired');
console.log(
'[AuthStateManager] Cache age:',
Math.floor((now - state.timestamp) / 1000 / 60),
'minutes',
);
await this.clearAuthState();
return false;
}
// Check if it's for the same working directory and auth method
const isSameContext =
state.workingDir === workingDir && state.authMethod === authMethod;
if (!isSameContext) {
console.log('[AuthStateManager] Working dir or auth method changed');
console.log('[AuthStateManager] Cached workingDir:', state.workingDir);
console.log('[AuthStateManager] Current workingDir:', workingDir);
console.log('[AuthStateManager] Cached authMethod:', state.authMethod);
console.log('[AuthStateManager] Current authMethod:', authMethod);
return false;
}
console.log('[AuthStateManager] Valid cached auth found');
return state.isAuthenticated;
}
/**
* Force check auth state without clearing cache
* This is useful for debugging to see what's actually cached
*/
async debugAuthState(): Promise<void> {
const state = await this.getAuthState();
console.log('[AuthStateManager] DEBUG - Current auth state:', state);
if (state) {
const now = Date.now();
const age = Math.floor((now - state.timestamp) / 1000 / 60);
const isExpired =
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
console.log('[AuthStateManager] DEBUG - Auth state age:', age, 'minutes');
console.log('[AuthStateManager] DEBUG - Auth state expired:', isExpired);
console.log(
'[AuthStateManager] DEBUG - Auth state valid:',
state.isAuthenticated,
);
}
}
/**
* Save successful authentication state
*/
async saveAuthState(workingDir: string, authMethod: string): Promise<void> {
// Ensure we have a valid context
if (!AuthStateManager.context) {
throw new Error(
'[AuthStateManager] No context available for saving auth state',
);
}
const state: AuthState = {
isAuthenticated: true,
authMethod,
workingDir,
timestamp: Date.now(),
};
console.log('[AuthStateManager] Saving auth state:', {
workingDir,
authMethod,
timestamp: new Date(state.timestamp).toISOString(),
});
await AuthStateManager.context.globalState.update(
AuthStateManager.AUTH_STATE_KEY,
state,
);
console.log('[AuthStateManager] Auth state saved');
// Verify the state was saved correctly
const savedState = await this.getAuthState();
console.log('[AuthStateManager] Verified saved state:', savedState);
}
/**
* Clear authentication state
*/
async clearAuthState(): Promise<void> {
// Ensure we have a valid context
if (!AuthStateManager.context) {
throw new Error(
'[AuthStateManager] No context available for clearing auth state',
);
}
console.log('[AuthStateManager] Clearing auth state');
const currentState = await this.getAuthState();
console.log(
'[AuthStateManager] Current state before clearing:',
currentState,
);
await AuthStateManager.context.globalState.update(
AuthStateManager.AUTH_STATE_KEY,
undefined,
);
console.log('[AuthStateManager] Auth state cleared');
// Verify the state was cleared
const newState = await this.getAuthState();
console.log('[AuthStateManager] State after clearing:', newState);
}
/**
* Get current auth state
*/
private async getAuthState(): Promise<AuthState | undefined> {
// Ensure we have a valid context
if (!AuthStateManager.context) {
console.log(
'[AuthStateManager] No context available for getting auth state',
);
return undefined;
}
const a = AuthStateManager.context.globalState.get<AuthState>(
AuthStateManager.AUTH_STATE_KEY,
);
console.log('[AuthStateManager] Auth state:', a);
return a;
}
/**
* Get auth state info for debugging
*/
async getAuthInfo(): Promise<string> {
const state = await this.getAuthState();
if (!state) {
return 'No cached auth';
}
const age = Math.floor((Date.now() - state.timestamp) / 1000 / 60);
return `Auth cached ${age}m ago, method: ${state.authMethod}`;
}
}

View File

@@ -7,22 +7,25 @@ import { AcpConnection } from './acpConnection.js';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
ApprovalModeValue,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
import { QwenSessionManager } from './qwenSessionManager.js';
import type { AuthStateManager } from './authStateManager.js';
import type {
ChatMessage,
PlanEntry,
ToolCallUpdateData,
QwenAgentCallbacks,
} from '../types/chatTypes.js';
import { QwenConnectionHandler } from '../services/qwenConnectionHandler.js';
import {
QwenConnectionHandler,
type QwenConnectionResult,
} from '../services/qwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { CliContextManager } from '../cli/cliContextManager.js';
import { authMethod } from '../types/acpTypes.js';
import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
@@ -31,6 +34,13 @@ export type { ChatMessage, PlanEntry, ToolCallUpdateData };
*
* Coordinates various modules and provides unified interface
*/
interface AgentConnectOptions {
autoAuthenticate?: boolean;
}
interface AgentSessionOptions {
autoAuthenticate?: boolean;
}
export class QwenAgentManager {
private connection: AcpConnection;
private sessionReader: QwenSessionReader;
@@ -42,9 +52,9 @@ export class QwenAgentManager {
// session/update notifications. We set this flag to route message chunks
// (user/assistant) as discrete chat messages instead of live streaming.
private rehydratingSessionId: string | null = null;
// Cache the last used AuthStateManager so internal calls (e.g. fallback paths)
// can reuse it and avoid forcing a fresh authentication unnecessarily.
private defaultAuthStateManager?: AuthStateManager;
// CLI is now the single source of truth for authentication state
// Deduplicate concurrent session/new attempts
private sessionCreateInFlight: Promise<string | null> | null = null;
// Callback storage
private callbacks: QwenAgentCallbacks = {};
@@ -120,10 +130,10 @@ export class QwenAgentManager {
return { optionId: 'allow_once' };
};
this.connection.onEndTurn = () => {
this.connection.onEndTurn = (reason?: string) => {
try {
if (this.callbacks.onEndTurn) {
this.callbacks.onEndTurn();
this.callbacks.onEndTurn(reason);
} else if (this.callbacks.onStreamChunk) {
// Fallback: send a zero-length chunk then rely on streamEnd elsewhere
this.callbacks.onStreamChunk('');
@@ -133,6 +143,20 @@ export class QwenAgentManager {
}
};
this.connection.onAuthenticateUpdate = (
data: AuthenticateUpdateNotification,
) => {
try {
// Handle authentication update notifications by showing VS Code notification
handleAuthenticateUpdate(data);
} catch (err) {
console.warn(
'[QwenAgentManager] onAuthenticateUpdate callback error:',
err,
);
}
};
// Initialize callback to surface available modes and current mode to UI
this.connection.onInitialized = (init: unknown) => {
try {
@@ -163,23 +187,19 @@ export class QwenAgentManager {
* Connect to Qwen service
*
* @param workingDir - Working directory
* @param authStateManager - Authentication state manager (optional)
* @param cliPath - CLI path (optional, if provided will override the path in configuration)
* @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
*/
async connect(
workingDir: string,
authStateManager?: AuthStateManager,
_cliPath?: string,
): Promise<void> {
cliEntryPath: string,
options?: AgentConnectOptions,
): Promise<QwenConnectionResult> {
this.currentWorkingDir = workingDir;
// Remember the provided authStateManager for future calls
this.defaultAuthStateManager = authStateManager;
await this.connectionHandler.connect(
return this.connectionHandler.connect(
this.connection,
this.sessionReader,
workingDir,
authStateManager,
_cliPath,
cliEntryPath,
options,
);
}
@@ -261,71 +281,59 @@ export class QwenAgentManager {
'[QwenAgentManager] Getting session list with version-aware strategy',
);
// Check if CLI supports session/list method
const cliContextManager = CliContextManager.getInstance();
const supportsSessionList = cliContextManager.supportsSessionList();
try {
console.log(
'[QwenAgentManager] Attempting to get session list via ACP method',
);
const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response);
console.log(
'[QwenAgentManager] CLI supports session/list:',
supportsSessionList,
);
// sendRequest resolves with the JSON-RPC "result" directly
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
// Older prototypes might return an array. Support both.
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
// Try ACP method first if supported
if (supportsSessionList) {
try {
console.log(
'[QwenAgentManager] Attempting to get session list via ACP method',
);
const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response);
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
// "result" directly (not the full AcpResponse). Treat it as unknown
// and carefully narrow before accessing `items` to satisfy strict TS.
if (res && typeof res === 'object' && 'items' in res) {
const itemsValue = (res as { items?: unknown }).items;
items = Array.isArray(itemsValue)
? (itemsValue as Array<Record<string, unknown>>)
: [];
}
// sendRequest resolves with the JSON-RPC "result" directly
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
// Older prototypes might return an array. Support both.
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
// "result" directly (not the full AcpResponse). Treat it as unknown
// and carefully narrow before accessing `items` to satisfy strict TS.
if (res && typeof res === 'object' && 'items' in res) {
const itemsValue = (res as { items?: unknown }).items;
items = Array.isArray(itemsValue)
? (itemsValue as Array<Record<string, unknown>>)
: [];
}
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
res,
items.length,
);
if (items.length > 0) {
const sessions = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
res,
items.length,
);
if (items.length > 0) {
const sessions = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
sessions.length,
);
return sessions;
}
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session list failed, falling back to file system method:',
error,
sessions.length,
);
return sessions;
}
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session list failed, falling back to file system method:',
error,
);
}
// Always fall back to file system method
@@ -345,8 +353,10 @@ export class QwenAgentManager {
name: this.sessionReader.getSessionTitle(session),
startTime: session.startTime,
lastUpdated: session.lastUpdated,
messageCount: session.messages.length,
messageCount: session.messageCount ?? session.messages.length,
projectHash: session.projectHash,
filePath: session.filePath,
cwd: session.cwd,
}),
);
@@ -380,62 +390,52 @@ export class QwenAgentManager {
const size = params?.size ?? 20;
const cursor = params?.cursor;
const cliContextManager = CliContextManager.getInstance();
const supportsSessionList = cliContextManager.supportsSessionList();
try {
const response = await this.connection.listSessions({
size,
...(cursor !== undefined ? { cursor } : {}),
});
// sendRequest resolves with the JSON-RPC "result" directly
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
if (supportsSessionList) {
try {
const response = await this.connection.listSessions({
size,
...(cursor !== undefined ? { cursor } : {}),
});
// sendRequest resolves with the JSON-RPC "result" directly
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
if (Array.isArray(res)) {
items = res;
} else if (typeof res === 'object' && res !== null && 'items' in res) {
const responseObject = res as {
items?: Array<Record<string, unknown>>;
};
items = Array.isArray(responseObject.items)
? responseObject.items
: [];
}
const mapped = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
const nextCursor: number | undefined =
typeof res === 'object' && res !== null && 'nextCursor' in res
? typeof res.nextCursor === 'number'
? res.nextCursor
: undefined
: undefined;
const hasMore: boolean =
typeof res === 'object' && res !== null && 'hasMore' in res
? Boolean(res.hasMore)
: false;
return { sessions: mapped, nextCursor, hasMore };
} catch (error) {
console.warn(
'[QwenAgentManager] Paged ACP session list failed:',
error,
);
// fall through to file system
if (Array.isArray(res)) {
items = res;
} else if (typeof res === 'object' && res !== null && 'items' in res) {
const responseObject = res as {
items?: Array<Record<string, unknown>>;
};
items = Array.isArray(responseObject.items) ? responseObject.items : [];
}
const mapped = items.map((item) => ({
id: item.sessionId || item.id,
sessionId: item.sessionId || item.id,
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
const nextCursor: number | undefined =
typeof res === 'object' && res !== null && 'nextCursor' in res
? typeof res.nextCursor === 'number'
? res.nextCursor
: undefined
: undefined;
const hasMore: boolean =
typeof res === 'object' && res !== null && 'hasMore' in res
? Boolean(res.hasMore)
: false;
return { sessions: mapped, nextCursor, hasMore };
} catch (error) {
console.warn('[QwenAgentManager] Paged ACP session list failed:', error);
// fall through to file system
}
// Fallback: file system for current project only (to match ACP semantics)
@@ -461,8 +461,10 @@ export class QwenAgentManager {
name: this.sessionReader.getSessionTitle(x.raw),
startTime: x.raw.startTime,
lastUpdated: x.raw.lastUpdated,
messageCount: x.raw.messages.length,
messageCount: x.raw.messageCount ?? x.raw.messages.length,
projectHash: x.raw.projectHash,
filePath: x.raw.filePath,
cwd: x.raw.cwd,
}));
const nextCursorVal =
page.length > 0 ? page[page.length - 1].mtime : undefined;
@@ -482,32 +484,28 @@ export class QwenAgentManager {
*/
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
try {
// Prefer reading CLI's JSONL if we can find filePath from session/list
const cliContextManager = CliContextManager.getInstance();
if (cliContextManager.supportsSessionList()) {
try {
const list = await this.getSessionList();
const item = list.find(
(s) => s.sessionId === sessionId || s.id === sessionId,
);
console.log(
'[QwenAgentManager] Session list item for filePath lookup:',
item,
);
if (
typeof item === 'object' &&
item !== null &&
'filePath' in item &&
typeof item.filePath === 'string'
) {
const messages = await this.readJsonlMessages(item.filePath);
// Even if messages array is empty, we should return it rather than falling back
// This ensures we don't accidentally show messages from a different session format
return messages;
}
} catch (e) {
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
try {
const list = await this.getSessionList();
const item = list.find(
(s) => s.sessionId === sessionId || s.id === sessionId,
);
console.log(
'[QwenAgentManager] Session list item for filePath lookup:',
item,
);
if (
typeof item === 'object' &&
item !== null &&
'filePath' in item &&
typeof item.filePath === 'string'
) {
const messages = await this.readJsonlMessages(item.filePath);
// Even if messages array is empty, we should return it rather than falling back
// This ensures we don't accidentally show messages from a different session format
return messages;
}
} catch (e) {
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
}
// Fallback: legacy JSON session files
@@ -705,7 +703,9 @@ export class QwenAgentManager {
const planText = planEntries
.map(
(entry: Record<string, unknown>, index: number) =>
`${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`,
`${index + 1}. ${
entry.description || entry.title || 'Unnamed step'
}`,
)
.join('\n');
msgs.push({
@@ -900,80 +900,6 @@ export class QwenAgentManager {
return this.saveSessionViaCommand(sessionId, tag);
}
/**
* Save session as checkpoint (using CLI format)
* Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
* Saves two copies with sessionId and conversationId to ensure recovery via either ID
*
* @param messages - Current session messages
* @param conversationId - Conversation ID (from VSCode extension)
* @returns Save result
*/
async saveCheckpoint(
messages: ChatMessage[],
conversationId: string,
): Promise<{ success: boolean; tag?: string; message?: string }> {
try {
console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START =====');
console.log('[QwenAgentManager] Conversation ID:', conversationId);
console.log('[QwenAgentManager] Message count:', messages.length);
console.log(
'[QwenAgentManager] Current working dir:',
this.currentWorkingDir,
);
console.log(
'[QwenAgentManager] Current session ID (from CLI):',
this.currentSessionId,
);
// In ACP mode, the CLI does not accept arbitrary slash commands like
// "/chat save". To ensure we never block on unsupported features,
// persist checkpoints directly to ~/.qwen/tmp using our SessionManager.
const qwenMessages = messages.map((m) => ({
// Generate minimal QwenMessage shape expected by the writer
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: new Date().toISOString(),
type: m.role === 'user' ? ('user' as const) : ('qwen' as const),
content: m.content,
}));
const tag = await this.sessionManager.saveCheckpoint(
qwenMessages,
conversationId,
this.currentWorkingDir,
this.currentSessionId || undefined,
);
return { success: true, tag };
} catch (error) {
console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED =====');
console.error('[QwenAgentManager] Error:', error);
console.error(
'[QwenAgentManager] Error stack:',
error instanceof Error ? error.stack : 'N/A',
);
return {
success: false,
message: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Save session directly to file system (without relying on ACP)
*
* @param messages - Current session messages
* @param sessionName - Session name
* @returns Save result
*/
async saveSessionDirect(
messages: ChatMessage[],
sessionName: string,
): Promise<{ success: boolean; sessionId?: string; message?: string }> {
// Use checkpoint format instead of session format
// This matches CLI's /chat save behavior
return this.saveCheckpoint(messages, sessionName);
}
/**
* Try to load session via ACP session/load method
* This method will only be used if CLI version supports it
@@ -985,16 +911,6 @@ export class QwenAgentManager {
sessionId: string,
cwdOverride?: string,
): Promise<unknown> {
// Check if CLI supports session/load method
const cliContextManager = CliContextManager.getInstance();
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
if (!supportsSessionLoad) {
throw new Error(
`CLI version does not support session/load method. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
);
}
try {
// Route upcoming session/update messages as discrete messages for replay
this.rehydratingSessionId = sessionId;
@@ -1068,32 +984,20 @@ export class QwenAgentManager {
sessionId,
);
// Check if CLI supports session/load method
const cliContextManager = CliContextManager.getInstance();
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
try {
console.log(
'[QwenAgentManager] Attempting to load session via ACP method',
);
await this.loadSessionViaAcp(sessionId);
console.log('[QwenAgentManager] Session loaded successfully via ACP');
console.log(
'[QwenAgentManager] CLI supports session/load:',
supportsSessionLoad,
);
// Try ACP method first if supported
if (supportsSessionLoad) {
try {
console.log(
'[QwenAgentManager] Attempting to load session via ACP method',
);
await this.loadSessionViaAcp(sessionId);
console.log('[QwenAgentManager] Session loaded successfully via ACP');
// After loading via ACP, we still need to get messages from file system
// In future, we might get them directly from the ACP response
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session load failed, falling back to file system method:',
error,
);
}
// After loading via ACP, we still need to get messages from file system
// In future, we might get them directly from the ACP response
} catch (error) {
console.warn(
'[QwenAgentManager] ACP session load failed, falling back to file system method:',
error,
);
}
// Always fall back to file system method
@@ -1161,16 +1065,6 @@ export class QwenAgentManager {
}
}
/**
* Load session, preferring ACP method if CLI version supports it
*
* @param sessionId - Session ID
* @returns Loaded session messages or null
*/
async loadSessionDirect(sessionId: string): Promise<ChatMessage[] | null> {
return this.loadSession(sessionId);
}
/**
* Create new session
*
@@ -1181,95 +1075,70 @@ export class QwenAgentManager {
*/
async createNewSession(
workingDir: string,
authStateManager?: AuthStateManager,
options?: AgentSessionOptions,
): Promise<string | null> {
const autoAuthenticate = options?.autoAuthenticate ?? true;
// Reuse existing session if present
if (this.connection.currentSessionId) {
return this.connection.currentSessionId;
}
// Deduplicate concurrent session/new attempts
if (this.sessionCreateInFlight) {
return this.sessionCreateInFlight;
}
console.log('[QwenAgentManager] Creating new session...');
// Check if we have valid cached authentication
let hasValidAuth = false;
// Prefer the provided authStateManager, otherwise fall back to the one
// remembered during connect(). This prevents accidental re-auth in
// fallback paths (e.g. session switching) when the handler didn't pass it.
const effectiveAuth = authStateManager || this.defaultAuthStateManager;
if (effectiveAuth) {
hasValidAuth = await effectiveAuth.hasValidAuth(workingDir, authMethod);
console.log(
'[QwenAgentManager] Has valid cached auth for new session:',
hasValidAuth,
);
}
// Only authenticate if we don't have valid cached auth
if (!hasValidAuth) {
console.log(
'[QwenAgentManager] Authenticating before creating session...',
);
this.sessionCreateInFlight = (async () => {
try {
await this.connection.authenticate(authMethod);
console.log('[QwenAgentManager] Authentication successful');
// Save auth state
if (effectiveAuth) {
console.log(
'[QwenAgentManager] Saving auth state after successful authentication',
);
await effectiveAuth.saveAuthState(workingDir, authMethod);
}
} catch (authError) {
console.error('[QwenAgentManager] Authentication failed:', authError);
// Clear potentially invalid cache
if (effectiveAuth) {
console.log(
'[QwenAgentManager] Clearing auth cache due to authentication failure',
);
await effectiveAuth.clearAuthState();
}
throw authError;
}
} else {
console.log(
'[QwenAgentManager] Skipping authentication - using valid cached auth',
);
}
// Try to create a new ACP session. If Qwen asks for auth despite our
// cached flag (e.g. fresh process or expired tokens), re-authenticate and retry.
try {
await this.connection.newSession(workingDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const requiresAuth =
msg.includes('Authentication required') ||
msg.includes('(code: -32000)');
if (requiresAuth) {
console.warn(
'[QwenAgentManager] session/new requires authentication. Retrying with authenticate...',
);
// Try to create a new ACP session. If Qwen asks for auth, let it handle authentication.
try {
await this.connection.authenticate(authMethod);
// Persist auth cache so subsequent calls can skip the web flow.
if (effectiveAuth) {
await effectiveAuth.saveAuthState(workingDir, authMethod);
}
await this.connection.newSession(workingDir);
} catch (reauthErr) {
// Clear potentially stale cache on failure and rethrow
if (effectiveAuth) {
await effectiveAuth.clearAuthState();
} catch (err) {
const requiresAuth = isAuthenticationRequiredError(err);
if (requiresAuth) {
if (!autoAuthenticate) {
console.warn(
'[QwenAgentManager] session/new requires authentication but auto-auth is disabled. Deferring until user logs in.',
);
throw err;
}
console.warn(
'[QwenAgentManager] session/new requires authentication. Retrying with authenticate...',
);
try {
// Let CLI handle authentication - it's the single source of truth
await this.connection.authenticate(authMethod);
console.log(
'[QwenAgentManager] createNewSession Authentication successful. Retrying session/new...',
);
// Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300));
await this.connection.newSession(workingDir);
} catch (reauthErr) {
console.error(
'[QwenAgentManager] Re-authentication failed:',
reauthErr,
);
throw reauthErr;
}
} else {
throw err;
}
throw reauthErr;
}
} else {
throw err;
const newSessionId = this.connection.currentSessionId;
console.log(
'[QwenAgentManager] New session created with ID:',
newSessionId,
);
return newSessionId;
} finally {
this.sessionCreateInFlight = null;
}
}
const newSessionId = this.connection.currentSessionId;
console.log(
'[QwenAgentManager] New session created with ID:',
newSessionId,
);
return newSessionId;
})();
return this.sessionCreateInFlight;
}
/**
@@ -1354,9 +1223,9 @@ export class QwenAgentManager {
/**
* Register end-of-turn callback
*
* @param callback - Called when ACP stopReason === 'end_turn'
* @param callback - Called when ACP stopReason is reported
*/
onEndTurn(callback: () => void): void {
onEndTurn(callback: (reason?: string) => void): void {
this.callbacks.onEndTurn = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}

View File

@@ -10,17 +10,15 @@
* Handles Qwen Agent connection establishment, authentication, and session creation
*/
import * as vscode from 'vscode';
import type { AcpConnection } from './acpConnection.js';
import type { QwenSessionReader } from '../services/qwenSessionReader.js';
import type { AuthStateManager } from '../services/authStateManager.js';
import {
CliVersionManager,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
} from '../cli/cliVersionManager.js';
import { CliContextManager } from '../cli/cliContextManager.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { authMethod } from '../types/acpTypes.js';
export interface QwenConnectionResult {
sessionCreated: boolean;
requiresAuth: boolean;
}
/**
* Qwen Connection Handler class
* Handles connection, authentication, and session initialization
@@ -30,62 +28,27 @@ export class QwenConnectionHandler {
* Connect to Qwen service and establish session
*
* @param connection - ACP connection instance
* @param sessionReader - Session reader instance
* @param workingDir - Working directory
* @param authStateManager - Authentication state manager (optional)
* @param cliPath - CLI path (optional, if provided will override the path in configuration)
*/
async connect(
connection: AcpConnection,
sessionReader: QwenSessionReader,
workingDir: string,
authStateManager?: AuthStateManager,
cliPath?: string,
): Promise<void> {
cliEntryPath: string,
options?: {
autoAuthenticate?: boolean;
},
): Promise<QwenConnectionResult> {
const connectId = Date.now();
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
// Check CLI version and features
const cliVersionManager = CliVersionManager.getInstance();
const versionInfo = await cliVersionManager.detectCliVersion();
console.log('[QwenAgentManager] CLI version info:', versionInfo);
// Store CLI context
const cliContextManager = CliContextManager.getInstance();
cliContextManager.setCurrentVersionInfo(versionInfo);
// Show warning if CLI version is below minimum requirement
if (!versionInfo.isSupported) {
// Wait to determine release version number
vscode.window.showWarningMessage(
`Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
);
}
const config = vscode.workspace.getConfiguration('qwenCode');
// Use the provided CLI path if available, otherwise use the configured path
const effectiveCliPath =
cliPath || config.get<string>('qwen.cliPath', 'qwen');
const autoAuthenticate = options?.autoAuthenticate ?? true;
let sessionCreated = false;
let requiresAuth = false;
// Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = [];
await connection.connect(effectiveCliPath, workingDir, extraArgs);
// Check if we have valid cached authentication
if (authStateManager) {
console.log('[QwenAgentManager] Checking for cached authentication...');
console.log('[QwenAgentManager] Working dir:', workingDir);
console.log('[QwenAgentManager] Auth method:', authMethod);
const hasValidAuth = await authStateManager.hasValidAuth(
workingDir,
authMethod,
);
console.log('[QwenAgentManager] Has valid auth:', hasValidAuth);
} else {
console.log('[QwenAgentManager] No authStateManager provided');
}
await connection.connect(cliEntryPath!, workingDir, extraArgs);
// Try to restore existing session or create new session
// Note: Auto-restore on connect is disabled to avoid surprising loads
@@ -99,88 +62,44 @@ export class QwenConnectionHandler {
'[QwenAgentManager] no sessionRestored, Creating new session...',
);
// Check if we have valid cached authentication
let hasValidAuth = false;
if (authStateManager) {
hasValidAuth = await authStateManager.hasValidAuth(
workingDir,
authMethod,
);
}
// Only authenticate if we don't have valid cached auth
if (!hasValidAuth) {
console.log(
'[QwenAgentManager] Authenticating before creating session...',
);
try {
await connection.authenticate(authMethod);
console.log('[QwenAgentManager] Authentication successful');
// Save auth state
if (authStateManager) {
console.log(
'[QwenAgentManager] Saving auth state after successful authentication',
);
console.log('[QwenAgentManager] Working dir for save:', workingDir);
console.log('[QwenAgentManager] Auth method for save:', authMethod);
await authStateManager.saveAuthState(workingDir, authMethod);
console.log('[QwenAgentManager] Auth state save completed');
}
} catch (authError) {
console.error('[QwenAgentManager] Authentication failed:', authError);
// Clear potentially invalid cache
if (authStateManager) {
console.log(
'[QwenAgentManager] Clearing auth cache due to authentication failure',
);
await authStateManager.clearAuthState();
}
throw authError;
}
} else {
console.log(
'[QwenAgentManager] Skipping authentication - using valid cached auth',
);
}
try {
console.log(
'[QwenAgentManager] Creating new session after authentication...',
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...',
);
await this.newSessionWithRetry(
connection,
workingDir,
3,
authMethod,
authStateManager,
autoAuthenticate,
);
console.log('[QwenAgentManager] New session created successfully');
// Ensure auth state is saved (prevent repeated authentication)
if (authStateManager) {
console.log(
'[QwenAgentManager] Saving auth state after successful session creation',
);
await authStateManager.saveAuthState(workingDir, authMethod);
}
sessionCreated = true;
} catch (sessionError) {
console.log(`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`);
console.log(`[QwenAgentManager] Error details:`, sessionError);
// Clear cache
if (authStateManager) {
console.log('[QwenAgentManager] Clearing auth cache due to failure');
await authStateManager.clearAuthState();
const needsAuth =
autoAuthenticate === false &&
isAuthenticationRequiredError(sessionError);
if (needsAuth) {
requiresAuth = true;
console.log(
'[QwenAgentManager] Session creation requires authentication; waiting for user-triggered login.',
);
} else {
console.log(
`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`,
);
console.log(`[QwenAgentManager] Error details:`, sessionError);
throw sessionError;
}
throw sessionError;
}
} else {
sessionCreated = true;
}
console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`);
return { sessionCreated, requiresAuth };
}
/**
@@ -195,7 +114,7 @@ export class QwenConnectionHandler {
workingDir: string,
maxRetries: number,
authMethod: string,
authStateManager?: AuthStateManager,
autoAuthenticate: boolean,
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
@@ -215,18 +134,26 @@ export class QwenConnectionHandler {
// If Qwen reports that authentication is required, try to
// authenticate on-the-fly once and retry without waiting.
const requiresAuth =
errorMessage.includes('Authentication required') ||
errorMessage.includes('(code: -32000)');
const requiresAuth = isAuthenticationRequiredError(error);
if (requiresAuth) {
if (!autoAuthenticate) {
console.log(
'[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.',
);
throw error;
}
console.log(
'[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...',
);
try {
await connection.authenticate(authMethod);
if (authStateManager) {
await authStateManager.saveAuthState(workingDir, authMethod);
}
// FIXME: @yiliang114 If there is no delay for a while, immediately executing
// newSession may cause the cli authorization jump to be triggered again
// Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300));
console.log(
'[QwenAgentManager] newSessionWithRetry Authentication successful',
);
// Retry immediately after successful auth
await connection.newSession(workingDir);
console.log(
@@ -238,9 +165,6 @@ export class QwenConnectionHandler {
'[QwenAgentManager] Re-authentication failed:',
authErr,
);
if (authStateManager) {
await authStateManager.clearAuthState();
}
// Fall through to retry logic below
}
}

View File

@@ -51,131 +51,7 @@ export class QwenSessionManager {
}
/**
* Save current conversation as a checkpoint (matching CLI's /chat save format)
* Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility
*
* @param messages - Current conversation messages
* @param conversationId - Conversation ID (from VSCode extension)
* @param sessionId - Session ID (from CLI tmp session file, optional)
* @param workingDir - Current working directory
* @returns Checkpoint tag
*/
async saveCheckpoint(
messages: QwenMessage[],
conversationId: string,
workingDir: string,
sessionId?: string,
): Promise<string> {
try {
console.log('[QwenSessionManager] ===== SAVEPOINT START =====');
console.log('[QwenSessionManager] Conversation ID:', conversationId);
console.log(
'[QwenSessionManager] Session ID:',
sessionId || 'not provided',
);
console.log('[QwenSessionManager] Working dir:', workingDir);
console.log('[QwenSessionManager] Message count:', messages.length);
// Get project directory (parent of chats directory)
const projectHash = this.getProjectHash(workingDir);
console.log('[QwenSessionManager] Project hash:', projectHash);
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
console.log('[QwenSessionManager] Project dir:', projectDir);
if (!fs.existsSync(projectDir)) {
console.log('[QwenSessionManager] Creating project directory...');
fs.mkdirSync(projectDir, { recursive: true });
console.log('[QwenSessionManager] Directory created');
} else {
console.log('[QwenSessionManager] Project directory already exists');
}
// Convert messages to checkpoint format (Gemini-style messages)
console.log(
'[QwenSessionManager] Converting messages to checkpoint format...',
);
const checkpointMessages = messages.map((msg, index) => {
console.log(
`[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`,
);
return {
role: msg.type === 'user' ? 'user' : 'model',
parts: [
{
text: msg.content,
},
],
};
});
console.log(
'[QwenSessionManager] Converted',
checkpointMessages.length,
'messages',
);
const jsonContent = JSON.stringify(checkpointMessages, null, 2);
console.log(
'[QwenSessionManager] JSON content length:',
jsonContent.length,
);
// Save with conversationId as primary tag
const convFilename = `checkpoint-${conversationId}.json`;
const convFilePath = path.join(projectDir, convFilename);
console.log(
'[QwenSessionManager] Saving checkpoint with conversationId:',
convFilePath,
);
fs.writeFileSync(convFilePath, jsonContent, 'utf-8');
// Also save with sessionId if provided (for compatibility with CLI session/load)
if (sessionId) {
const sessionFilename = `checkpoint-${sessionId}.json`;
const sessionFilePath = path.join(projectDir, sessionFilename);
console.log(
'[QwenSessionManager] Also saving checkpoint with sessionId:',
sessionFilePath,
);
fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8');
}
// Verify primary file exists
if (fs.existsSync(convFilePath)) {
const stats = fs.statSync(convFilePath);
console.log(
'[QwenSessionManager] Primary checkpoint verified, size:',
stats.size,
);
} else {
console.error(
'[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!',
);
}
console.log('[QwenSessionManager] ===== CHECKPOINT SAVED =====');
console.log('[QwenSessionManager] Primary path:', convFilePath);
if (sessionId) {
console.log(
'[QwenSessionManager] Secondary path (sessionId):',
path.join(projectDir, `checkpoint-${sessionId}.json`),
);
}
return conversationId;
} catch (error) {
console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED =====');
console.error('[QwenSessionManager] Error:', error);
console.error(
'[QwenSessionManager] Error stack:',
error instanceof Error ? error.stack : 'N/A',
);
throw error;
}
}
/**
* Save current conversation as a named session (checkpoint-like functionality)
* Save current conversation as a named session
*
* @param messages - Current conversation messages
* @param sessionName - Name/tag for the saved session

View File

@@ -7,6 +7,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as readline from 'readline';
import * as crypto from 'crypto';
export interface QwenMessage {
id: string;
@@ -32,6 +34,9 @@ export interface QwenSession {
lastUpdated: string;
messages: QwenMessage[];
filePath?: string;
messageCount?: number;
firstUserText?: string;
cwd?: string;
}
export class QwenSessionReader {
@@ -96,11 +101,17 @@ export class QwenSessionReader {
return sessions;
}
const files = fs
.readdirSync(chatsDir)
.filter((f) => f.startsWith('session-') && f.endsWith('.json'));
const files = fs.readdirSync(chatsDir);
for (const file of files) {
const jsonSessionFiles = files.filter(
(f) => f.startsWith('session-') && f.endsWith('.json'),
);
const jsonlSessionFiles = files.filter((f) =>
/^[0-9a-fA-F-]{32,36}\.jsonl$/.test(f),
);
for (const file of jsonSessionFiles) {
const filePath = path.join(chatsDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -116,6 +127,23 @@ export class QwenSessionReader {
}
}
// Support new JSONL session format produced by the CLI
for (const file of jsonlSessionFiles) {
const filePath = path.join(chatsDir, file);
try {
const session = await this.readJsonlSession(filePath, false);
if (session) {
sessions.push(session);
}
} catch (error) {
console.error(
'[QwenSessionReader] Failed to read JSONL session file:',
filePath,
error,
);
}
}
return sessions;
}
@@ -128,7 +156,25 @@ export class QwenSessionReader {
): Promise<QwenSession | null> {
// First try to find in all projects
const sessions = await this.getAllSessions(undefined, true);
return sessions.find((s) => s.sessionId === sessionId) || null;
const found = sessions.find((s) => s.sessionId === sessionId);
if (!found) {
return null;
}
// If the session points to a JSONL file, load full content on demand
if (
found.filePath &&
found.filePath.endsWith('.jsonl') &&
found.messages.length === 0
) {
const hydrated = await this.readJsonlSession(found.filePath, true);
if (hydrated) {
return hydrated;
}
}
return found;
}
/**
@@ -136,7 +182,6 @@ export class QwenSessionReader {
* Qwen CLI uses SHA256 hash of project path
*/
private async getProjectHash(workingDir: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(workingDir).digest('hex');
}
@@ -144,6 +189,14 @@ export class QwenSessionReader {
* Get session title (based on first user message)
*/
getSessionTitle(session: QwenSession): string {
// Prefer cached prompt text to avoid loading messages for JSONL sessions
if (session.firstUserText) {
return (
session.firstUserText.substring(0, 50) +
(session.firstUserText.length > 50 ? '...' : '')
);
}
const firstUserMessage = session.messages.find((m) => m.type === 'user');
if (firstUserMessage) {
// Extract first 50 characters as title
@@ -155,6 +208,137 @@ export class QwenSessionReader {
return 'Untitled Session';
}
/**
* Parse a JSONL session file written by the CLI.
* When includeMessages is false, only lightweight metadata is returned.
*/
private async readJsonlSession(
filePath: string,
includeMessages: boolean,
): Promise<QwenSession | null> {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const stats = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
const messages: QwenMessage[] = [];
const seenUuids = new Set<string>();
let sessionId: string | undefined;
let startTime: string | undefined;
let firstUserText: string | undefined;
let cwd: string | undefined;
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let obj: Record<string, unknown>;
try {
obj = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (!sessionId && typeof obj.sessionId === 'string') {
sessionId = obj.sessionId;
}
if (!startTime && typeof obj.timestamp === 'string') {
startTime = obj.timestamp;
}
if (!cwd && typeof obj.cwd === 'string') {
cwd = obj.cwd;
}
const type = typeof obj.type === 'string' ? obj.type : '';
if (type === 'user' || type === 'assistant') {
const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined;
if (uuid) {
seenUuids.add(uuid);
}
const text = this.contentToText(obj.message);
if (includeMessages) {
messages.push({
id: uuid || `${messages.length}`,
timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '',
type: type === 'user' ? 'user' : 'qwen',
content: text,
});
}
if (!firstUserText && type === 'user' && text) {
firstUserText = text;
}
}
}
// Ensure stream is closed
rl.close();
if (!sessionId) {
return null;
}
const projectHash = cwd
? await this.getProjectHash(cwd)
: path.basename(path.dirname(path.dirname(filePath)));
return {
sessionId,
projectHash,
startTime: startTime || new Date(stats.birthtimeMs).toISOString(),
lastUpdated: new Date(stats.mtimeMs).toISOString(),
messages: includeMessages ? messages : [],
filePath,
messageCount: seenUuids.size,
firstUserText,
cwd,
};
} catch (error) {
console.error(
'[QwenSessionReader] Failed to parse JSONL session:',
error,
);
return null;
}
}
// Extract plain text from CLI Content structure
private contentToText(message: unknown): string {
try {
if (typeof message !== 'object' || message === null) {
return '';
}
const typed = message as { parts?: unknown[] };
const parts = Array.isArray(typed.parts) ? typed.parts : [];
const texts: string[] = [];
for (const part of parts) {
if (typeof part !== 'object' || part === null) {
continue;
}
const p = part as Record<string, unknown>;
if (typeof p.text === 'string') {
texts.push(p.text);
} else if (typeof p.data === 'string') {
texts.push(p.data);
}
}
return texts.join('\n');
} catch {
return '';
}
}
/**
* Delete session file
*/

View File

@@ -10,7 +10,8 @@
* Handles session updates from ACP and dispatches them to appropriate callbacks
*/
import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js';
import type { AcpSessionUpdate } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
/**

View File

@@ -3,6 +3,7 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export const JSONRPC_VERSION = '2.0' as const;
export const authMethod = 'qwen-oauth';
@@ -138,8 +139,6 @@ export interface PlanUpdate extends BaseSessionUpdate {
};
}
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
export {
ApprovalMode,
APPROVAL_MODE_MAP,
@@ -167,6 +166,13 @@ export interface CurrentModeUpdate extends BaseSessionUpdate {
};
}
// Authenticate update (sent by agent during authentication process)
export interface AuthenticateUpdateNotification {
_meta: {
authUri: string;
};
}
export type AcpSessionUpdate =
| UserMessageChunkUpdate
| AgentMessageChunkUpdate

View File

@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Type for approval mode values
* Used in ACP protocol for controlling agent behavior
*/
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';

View File

@@ -3,7 +3,8 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js';
import type { AcpPermissionRequest } from './acpTypes.js';
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export interface ChatMessage {
role: 'user' | 'assistant';
@@ -34,7 +35,7 @@ export interface QwenAgentCallbacks {
onToolCall?: (update: ToolCallUpdateData) => void;
onPlan?: (entries: PlanEntry[]) => void;
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
onEndTurn?: () => void;
onEndTurn?: (reason?: string) => void;
onModeInfo?: (info: {
currentModeId?: ApprovalModeValue;
availableModes?: Array<{

View File

@@ -5,7 +5,11 @@
*/
import type { ChildProcess } from 'child_process';
import type { AcpSessionUpdate, AcpPermissionRequest } from './acpTypes.js';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
} from './acpTypes.js';
export interface PendingRequest<T = unknown> {
resolve: (value: T) => void;
@@ -19,7 +23,8 @@ export interface AcpConnectionCallbacks {
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}>;
onEndTurn: () => void;
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;
onEndTurn: (reason?: string) => void;
}
export interface AcpConnectionState {

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const AUTH_ERROR_PATTERNS = [
'Authentication required', // Standard authentication request message
'(code: -32000)', // RPC error code -32000 indicates authentication failure
'Unauthorized', // HTTP unauthorized error
'Invalid token', // Invalid token
'Session expired', // Session expired
];
/**
* Determines if the given error is authentication-related
*/
export const isAuthenticationRequiredError = (error: unknown): boolean => {
// Null check to avoid unnecessary processing
if (!error) {
return false;
}
// Extract error message text
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error);
// Match authentication-related errors using predefined patterns
return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern));
};

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
// Store reference to the current notification
let currentNotification: Thenable<string | undefined> | null = null;
/**
* Handle authentication update notifications by showing a VS Code notification
* with the authentication URI and action buttons.
*
* @param data - Authentication update notification data containing the auth URI
*/
export function handleAuthenticateUpdate(
data: AuthenticateUpdateNotification,
): void {
const authUri = data._meta.authUri;
// Store reference to the current notification
currentNotification = vscode.window.showInformationMessage(
`Qwen Code needs authentication. Click an action below:`,
'Open in Browser',
'Copy Link',
'Dismiss',
);
currentNotification.then((selection) => {
if (selection === 'Open in Browser') {
// Open the authentication URI in the default browser
vscode.env.openExternal(vscode.Uri.parse(authUri));
vscode.window.showInformationMessage(
'Opening authentication page in your browser...',
);
} else if (selection === 'Copy Link') {
// Copy the authentication URI to clipboard
vscode.env.clipboard.writeText(authUri);
vscode.window.showInformationMessage(
'Authentication link copied to clipboard!',
);
}
// Clear the notification reference after user interaction
currentNotification = null;
});
}

View File

@@ -29,6 +29,7 @@ import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
import { EmptyState } from './components/layout/EmptyState.js';
import { Onboarding } from './components/layout/Onboarding.js';
import { type CompletionItem } from '../types/completionItemTypes.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
import { ChatHeader } from './components/layout/ChatHeader.js';
@@ -43,7 +44,7 @@ import { InputForm } from './components/layout/InputForm.js';
import { SessionSelector } from './components/layout/SessionSelector.js';
import { FileIcon, UserIcon } from './components/icons/index.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../types/chatTypes.js';
export const App: React.FC = () => {
@@ -67,6 +68,8 @@ export const App: React.FC = () => {
toolCall: PermissionToolCall;
} | null>(null);
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
const messagesEndRef = useRef<HTMLDivElement>(
null,
) as React.RefObject<HTMLDivElement>;
@@ -90,9 +93,13 @@ export const App: React.FC = () => {
const getCompletionItems = React.useCallback(
async (trigger: '@' | '/', query: string): Promise<CompletionItem[]> => {
if (trigger === '@') {
if (!fileContext.hasRequestedFiles) {
fileContext.requestWorkspaceFiles();
}
console.log('[App] getCompletionItems @ called', {
query,
requested: fileContext.hasRequestedFiles,
workspaceFiles: fileContext.workspaceFiles.length,
});
// 始终根据当前 query 触发请求,让 hook 判断是否需要真正请求
fileContext.requestWorkspaceFiles(query);
const fileIcon = <FileIcon />;
const allItems: CompletionItem[] = fileContext.workspaceFiles.map(
@@ -109,7 +116,6 @@ export const App: React.FC = () => {
);
if (query && query.length >= 1) {
fileContext.requestWorkspaceFiles(query);
const lowerQuery = query.toLowerCase();
return allItems.filter(
(item) =>
@@ -154,20 +160,42 @@ export const App: React.FC = () => {
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
// Track a lightweight signature of workspace files to detect content changes even when length is unchanged
const workspaceFilesSignature = useMemo(
() =>
fileContext.workspaceFiles
.map(
(file) =>
`${file.id}|${file.label}|${file.description ?? ''}|${file.path}`,
)
.join('||'),
[fileContext.workspaceFiles],
);
// When workspace files update while menu open for @, refresh items so the first @ shows the list
// Note: Avoid depending on the entire `completion` object here, since its identity
// changes on every render which would retrigger this effect and can cause a refresh loop.
useEffect(() => {
if (completion.isOpen && completion.triggerChar === '@') {
// Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search
if (
completion.isOpen &&
completion.triggerChar === '@' &&
!completion.query
) {
// Only refresh items; do not change other completion state to avoid re-renders loops
completion.refreshCompletion();
}
// Only re-run when the actual data source changes, not on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]);
}, [
workspaceFilesSignature,
completion.isOpen,
completion.triggerChar,
completion.query,
]);
// Message submission
const handleSubmit = useMessageSubmit({
const { handleSubmit: submitMessage } = useMessageSubmit({
inputText,
setInputText,
messageHandling,
@@ -176,6 +204,7 @@ export const App: React.FC = () => {
vscode,
inputFieldRef,
isStreaming: messageHandling.isStreaming,
isWaitingForResponse: messageHandling.isWaitingForResponse,
});
// Handle cancel/stop from the input bar
@@ -218,6 +247,7 @@ export const App: React.FC = () => {
inputFieldRef,
setInputText,
setEditMode,
setIsAuthenticated,
});
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
@@ -331,6 +361,14 @@ export const App: React.FC = () => {
completedToolCalls,
]);
// Set loading state to false after initial mount and when we have authentication info
useEffect(() => {
// If we have determined authentication status, we're done loading
if (isAuthenticated !== null) {
setIsLoading(false);
}
}, [isAuthenticated]);
// Handle permission response
const handlePermissionResponse = useCallback(
(optionId: string) => {
@@ -487,6 +525,22 @@ export const App: React.FC = () => {
setThinkingEnabled((prev) => !prev);
};
// When user sends a message after scrolling up, re-pin and jump to the bottom
const handleSubmitWithScroll = useCallback(
(e: React.FormEvent) => {
setPinnedToBottom(true);
const container = messagesContainerRef.current;
if (container) {
const top = container.scrollHeight - container.clientHeight;
container.scrollTo({ top });
}
submitMessage(e);
},
[submitMessage],
);
// Create unified message array containing all types of messages and tool calls
const allMessages = useMemo<
Array<{
@@ -621,7 +675,19 @@ export const App: React.FC = () => {
allMessages.length > 0;
return (
<div className="chat-container">
<div className="chat-container relative">
{/* Top-level loading overlay */}
{isLoading && (
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground text-sm">
Preparing Qwen Code...
</p>
</div>
</div>
)}
<SessionSelector
visible={sessionManagement.showSessionSelector}
sessions={sessionManagement.filteredSessions}
@@ -646,96 +712,110 @@ export const App: React.FC = () => {
<div
ref={messagesContainerRef}
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
>
{!hasContent ? (
<EmptyState />
{!hasContent && !isLoading ? (
isAuthenticated === false ? (
<Onboarding
onLogin={() => {
vscode.postMessage({ type: 'login', data: {} });
messageHandling.setWaitingForResponse(
'Logging in to Qwen Code...',
);
}}
/>
) : isAuthenticated === null ? (
<EmptyState loadingMessage="Checking login status…" />
) : (
<EmptyState isAuthenticated />
)
) : (
<>
{/* Render all messages and tool calls */}
{renderMessages()}
{/* Flow-in persistent slot: keeps a small constant height so toggling */}
{/* the waiting message doesn't change list height to zero. When */}
{/* active, render the waiting message inline (not fixed). */}
<div className="waiting-message-slot min-h-[28px]">
{messageHandling.isWaitingForResponse &&
messageHandling.loadingMessage && (
{/* Waiting message positioned fixed above the input form to avoid layout shifts */}
{messageHandling.isWaitingForResponse &&
messageHandling.loadingMessage && (
<div className="waiting-message-slot min-h-[28px]">
<WaitingMessage
loadingMessage={messageHandling.loadingMessage}
/>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
<InputForm
inputText={inputText}
inputFieldRef={inputFieldRef}
isStreaming={messageHandling.isStreaming}
isWaitingForResponse={messageHandling.isWaitingForResponse}
isComposing={isComposing}
editMode={editMode}
thinkingEnabled={thinkingEnabled}
activeFileName={fileContext.activeFileName}
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmit.handleSubmit}
onCancel={handleCancel}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={fileContext.focusActiveEditor}
onToggleSkipAutoActiveContext={() =>
setSkipAutoActiveContext((v) => !v)
}
onShowCommandMenu={async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
{isAuthenticated && (
<InputForm
inputText={inputText}
inputFieldRef={inputFieldRef}
isStreaming={messageHandling.isStreaming}
isWaitingForResponse={messageHandling.isWaitingForResponse}
isComposing={isComposing}
editMode={editMode}
thinkingEnabled={thinkingEnabled}
activeFileName={fileContext.activeFileName}
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmitWithScroll}
onCancel={handleCancel}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={fileContext.focusActiveEditor}
onToggleSkipAutoActiveContext={() =>
setSkipAutoActiveContext((v) => !v)
}
onShowCommandMenu={async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
const selection = window.getSelection();
let position = { top: 0, left: 0 };
const selection = window.getSelection();
let position = { top: 0, left: 0 };
if (selection && selection.rangeCount > 0) {
try {
const range = selection.getRangeAt(0);
const rangeRect = range.getBoundingClientRect();
if (rangeRect.top > 0 && rangeRect.left > 0) {
position = {
top: rangeRect.top,
left: rangeRect.left,
};
} else {
if (selection && selection.rangeCount > 0) {
try {
const range = selection.getRangeAt(0);
const rangeRect = range.getBoundingClientRect();
if (rangeRect.top > 0 && rangeRect.left > 0) {
position = {
top: rangeRect.top,
left: rangeRect.left,
};
} else {
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} catch (error) {
console.error('[App] Error getting cursor position:', error);
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} catch (error) {
console.error('[App] Error getting cursor position:', error);
} else {
const inputRect = inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} else {
const inputRect = inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
await completion.openCompletion('/', '', position);
}
}}
onAttachContext={handleAttachContextClick}
completionIsOpen={completion.isOpen}
completionItems={completion.items}
onCompletionSelect={handleCompletionSelect}
onCompletionClose={completion.closeCompletion}
/>
)}
await completion.openCompletion('/', '', position);
}
}}
onAttachContext={handleAttachContextClick}
completionIsOpen={completion.isOpen}
completionItems={completion.items}
onCompletionSelect={handleCompletionSelect}
onCompletionClose={completion.closeCompletion}
/>
{permissionRequest && (
{isAuthenticated && permissionRequest && (
<PermissionDrawer
isOpen={!!permissionRequest}
options={permissionRequest.options}

View File

@@ -73,11 +73,4 @@ export class MessageHandler {
appendStreamContent(chunk: string): void {
this.router.appendStreamContent(chunk);
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.router.getIsSavingCheckpoint();
}
}

View File

@@ -8,21 +8,18 @@ import * as vscode from 'vscode';
import { QwenAgentManager } from '../services/qwenAgentManager.js';
import { ConversationStore } from '../services/conversationStore.js';
import type { AcpPermissionRequest } from '../types/acpTypes.js';
import { CliDetector } from '../cli/cliDetector.js';
import { AuthStateManager } from '../services/authStateManager.js';
import { PanelManager } from '../webview/PanelManager.js';
import { MessageHandler } from '../webview/MessageHandler.js';
import { WebViewContent } from '../webview/WebViewContent.js';
import { CliInstaller } from '../cli/cliInstaller.js';
import { getFileName } from './utils/webviewUtils.js';
import { authMethod, type ApprovalModeValue } from '../types/acpTypes.js';
import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
export class WebViewProvider {
private panelManager: PanelManager;
private messageHandler: MessageHandler;
private agentManager: QwenAgentManager;
private conversationStore: ConversationStore;
private authStateManager: AuthStateManager;
private disposables: vscode.Disposable[] = [];
private agentInitialized = false; // Track if agent has been initialized
// Track a pending permission request and its resolver so extension commands
@@ -34,12 +31,11 @@ export class WebViewProvider {
private currentModeId: ApprovalModeValue | null = null;
constructor(
context: vscode.ExtensionContext,
private context: vscode.ExtensionContext,
private extensionUri: vscode.Uri,
) {
this.agentManager = new QwenAgentManager();
this.conversationStore = new ConversationStore(context);
this.authStateManager = AuthStateManager.getInstance(context);
this.panelManager = new PanelManager(extensionUri, () => {
// Panel dispose callback
this.disposables.forEach((d) => d.dispose());
@@ -122,12 +118,15 @@ export class WebViewProvider {
});
});
// Setup end-turn handler from ACP stopReason=end_turn
this.agentManager.onEndTurn(() => {
// 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
this.sendMessageToWebView({
type: 'streamEnd',
data: { timestamp: Date.now(), reason: 'end_turn' },
data: {
timestamp: Date.now(),
reason: reason || 'end_turn',
},
});
});
@@ -522,40 +521,14 @@ export class WebViewProvider {
*/
private async attemptAuthStateRestoration(): Promise<void> {
try {
if (this.authStateManager) {
// Debug current auth state
await this.authStateManager.debugAuthState();
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
const hasValidAuth = await this.authStateManager.hasValidAuth(
workingDir,
authMethod,
);
console.log('[WebViewProvider] Has valid cached auth:', hasValidAuth);
if (hasValidAuth) {
console.log(
'[WebViewProvider] Valid auth found, attempting connection...',
);
// Try to connect with cached auth
await this.initializeAgentConnection();
} else {
console.log(
'[WebViewProvider] No valid auth found, rendering empty conversation',
);
// Render the chat UI immediately without connecting
await this.initializeEmptyConversation();
}
} else {
console.log(
'[WebViewProvider] No auth state manager, rendering empty conversation',
);
await this.initializeEmptyConversation();
}
} catch (_error) {
console.error('[WebViewProvider] Auth state restoration failed:', _error);
// Fallback to rendering empty conversation
console.log('[WebViewProvider] Attempting connection...');
// Attempt a connection to detect prior auth without forcing login
await this.initializeAgentConnection({ autoAuthenticate: false });
} catch (error) {
console.error(
'[WebViewProvider] Error in attemptAuthStateRestoration:',
error,
);
await this.initializeEmptyConversation();
}
}
@@ -564,70 +537,88 @@ export class WebViewProvider {
* Initialize agent connection and session
* Can be called from show() or via /login command
*/
async initializeAgentConnection(): Promise<void> {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
async initializeAgentConnection(options?: {
autoAuthenticate?: boolean;
}): Promise<void> {
return this.doInitializeAgentConnection(options);
}
console.log(
'[WebViewProvider] Starting initialization, workingDir:',
workingDir,
);
console.log(
'[WebViewProvider] AuthStateManager available:',
!!this.authStateManager,
);
/**
* Internal: perform actual connection/initialization (no auth locking).
*/
private async doInitializeAgentConnection(options?: {
autoAuthenticate?: boolean;
}): Promise<void> {
const autoAuthenticate = options?.autoAuthenticate ?? true;
const run = async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
// Check if CLI is installed before attempting to connect
const cliDetection = await CliDetector.detectQwenCli();
if (!cliDetection.isInstalled) {
console.log(
'[WebViewProvider] Qwen CLI not detected, skipping agent connection',
'[WebViewProvider] Starting initialization, workingDir:',
workingDir,
);
console.log('[WebViewProvider] CLI detection error:', cliDetection.error);
// Show VSCode notification with installation option
await CliInstaller.promptInstallation();
// Initialize empty conversation (can still browse history)
await this.initializeEmptyConversation();
} else {
console.log(
'[WebViewProvider] Qwen CLI detected, attempting connection...',
`[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`,
);
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath);
console.log('[WebViewProvider] CLI version:', cliDetection.version);
const bundledCliEntry = vscode.Uri.joinPath(
this.extensionUri,
'dist',
'qwen-cli',
'cli.js',
).fsPath;
try {
console.log('[WebViewProvider] Connecting to agent...');
console.log(
'[WebViewProvider] Using authStateManager:',
!!this.authStateManager,
);
const authInfo = await this.authStateManager.getAuthInfo();
console.log('[WebViewProvider] Auth cache status:', authInfo);
// Pass the detected CLI path to ensure we use the correct installation
await this.agentManager.connect(
const connectResult = await this.agentManager.connect(
workingDir,
this.authStateManager,
cliDetection.cliPath,
bundledCliEntry,
options,
);
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// Load messages from the current Qwen session
await this.loadCurrentSessionMessages();
// If authentication is required and autoAuthenticate is false,
// 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',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
// Initialize empty conversation to allow browsing history
await this.initializeEmptyConversation();
return;
}
// Notify webview that agent is connected
this.sendMessageToWebView({
type: 'agentConnected',
data: {},
});
if (connectResult.requiresAuth) {
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
}
// Load messages from the current Qwen session
const sessionReady = await this.loadCurrentSessionMessages(options);
if (sessionReady) {
// Notify webview that agent is connected
this.sendMessageToWebView({
type: 'agentConnected',
data: {},
});
} else {
console.log(
'[WebViewProvider] Session creation deferred until user logs in.',
);
}
} catch (_error) {
console.error('[WebViewProvider] Agent connection error:', _error);
// Clear auth cache on error (might be auth issue)
await this.authStateManager.clearAuthState();
vscode.window.showWarningMessage(
`Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
);
@@ -642,7 +633,9 @@ export class WebViewProvider {
},
});
}
}
};
return run();
}
/**
@@ -651,29 +644,16 @@ export class WebViewProvider {
*/
async forceReLogin(): Promise<void> {
console.log('[WebViewProvider] Force re-login requested');
console.log(
'[WebViewProvider] Current authStateManager:',
!!this.authStateManager,
);
await vscode.window.withProgress(
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Logging in to Qwen Code... ',
cancellable: false,
},
async (progress) => {
try {
progress.report({ message: 'Preparing sign-in...' });
// Clear existing auth cache
if (this.authStateManager) {
await this.authStateManager.clearAuthState();
console.log('[WebViewProvider] Auth cache cleared');
} else {
console.log('[WebViewProvider] No authStateManager to clear');
}
// Disconnect existing connection if any
if (this.agentInitialized) {
try {
@@ -693,19 +673,11 @@ export class WebViewProvider {
});
// Reinitialize connection (will trigger fresh authentication)
await this.initializeAgentConnection();
await this.doInitializeAgentConnection({ autoAuthenticate: true });
console.log(
'[WebViewProvider] Force re-login completed successfully',
);
// Ensure auth state is saved after successful re-login
if (this.authStateManager) {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
await this.authStateManager.saveAuthState(workingDir, authMethod);
console.log('[WebViewProvider] Auth state saved after re-login');
}
// Send success notification to WebView
this.sendMessageToWebView({
type: 'loginSuccess',
@@ -784,7 +756,11 @@ export class WebViewProvider {
* Load messages from current Qwen session
* Skips session restoration and creates a new session directly
*/
private async loadCurrentSessionMessages(): Promise<void> {
private async loadCurrentSessionMessages(options?: {
autoAuthenticate?: boolean;
}): Promise<boolean> {
const autoAuthenticate = options?.autoAuthenticate ?? true;
let sessionReady = false;
try {
console.log(
'[WebViewProvider] Initializing with new session (skipping restoration)',
@@ -793,29 +769,49 @@ export class WebViewProvider {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
// Skip session restoration entirely and create a new session directly
try {
await this.agentManager.createNewSession(
workingDir,
this.authStateManager,
);
console.log('[WebViewProvider] ACP session created successfully');
// Ensure auth state is saved after successful session creation
if (this.authStateManager) {
await this.authStateManager.saveAuthState(workingDir, authMethod);
// avoid creating another session if connect() already created one.
if (!this.agentManager.currentSessionId) {
if (!autoAuthenticate) {
console.log(
'[WebViewProvider] Auth state saved after session creation',
'[WebViewProvider] Skipping ACP session creation until user logs in.',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
} else {
try {
await this.agentManager.createNewSession(workingDir, {
autoAuthenticate,
});
console.log('[WebViewProvider] ACP session created successfully');
sessionReady = true;
} catch (sessionError) {
const requiresAuth = isAuthenticationRequiredError(sessionError);
if (requiresAuth && !autoAuthenticate) {
console.log(
'[WebViewProvider] ACP session requires authentication; waiting for explicit login.',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
} else {
console.error(
'[WebViewProvider] Failed to create ACP session:',
sessionError,
);
vscode.window.showWarningMessage(
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
);
}
}
}
} catch (sessionError) {
console.error(
'[WebViewProvider] Failed to create ACP session:',
sessionError,
);
vscode.window.showWarningMessage(
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
} else {
console.log(
'[WebViewProvider] Existing ACP session detected, skipping new session creation',
);
sessionReady = true;
}
await this.initializeEmptyConversation();
@@ -828,7 +824,10 @@ export class WebViewProvider {
`Failed to load session messages: ${_error}`,
);
await this.initializeEmptyConversation();
return false;
}
return sessionReady;
}
/**
@@ -974,17 +973,6 @@ export class WebViewProvider {
this.agentManager.disconnect();
}
/**
* Clear authentication cache for this WebViewProvider instance
*/
async clearAuthCache(): Promise<void> {
console.log('[WebViewProvider] Clearing auth cache for this instance');
if (this.authStateManager) {
await this.authStateManager.clearAuthState();
this.resetAgentState();
}
}
/**
* Restore an existing WebView panel (called during VSCode restart)
* This sets up the panel with all event listeners
@@ -992,8 +980,7 @@ export class WebViewProvider {
async restorePanel(panel: vscode.WebviewPanel): Promise<void> {
console.log('[WebViewProvider] Restoring WebView panel');
console.log(
'[WebViewProvider] Current authStateManager in restore:',
!!this.authStateManager,
'[WebViewProvider] Using CLI-managed authentication in restore',
);
this.panelManager.setPanel(panel);
@@ -1196,18 +1183,13 @@ export class WebViewProvider {
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
// Create new Qwen session via agent manager
await this.agentManager.createNewSession(
workingDir,
this.authStateManager,
);
await this.agentManager.createNewSession(workingDir);
// Clear current conversation UI
this.sendMessageToWebView({
type: 'conversationCleared',
data: {},
});
console.log('[WebViewProvider] New session created successfully');
} catch (_error) {
console.error('[WebViewProvider] Failed to create new session:', _error);
vscode.window.showErrorMessage(`Failed to create new session: ${_error}`);

View File

@@ -92,9 +92,8 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
ref={containerRef}
role="menu"
className={[
// Semantic class name for readability (no CSS attached)
'completion-menu',
// Positioning and container styling (Tailwind)
// Positioning and container styling
'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]',

View File

@@ -7,24 +7,56 @@
import type React from 'react';
import { generateIconUrl } from '../../utils/resourceUrl.js';
export const EmptyState: React.FC = () => {
interface EmptyStateProps {
isAuthenticated?: boolean;
loadingMessage?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
isAuthenticated = false,
loadingMessage,
}) => {
// Generate icon URL using the utility function
const iconUri = generateIconUrl('icon.png');
const description = loadingMessage
? 'Preparing Qwen Code…'
: isAuthenticated
? 'What would you like to do? Ask about this codebase or we can start writing code.'
: 'Welcome! Please log in to start using Qwen Code.';
return (
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
<div className="flex flex-col items-center gap-8 w-full">
{/* Qwen Logo */}
<div className="flex flex-col items-center gap-6">
<img
src={iconUri}
alt="Qwen Logo"
className="w-[60px] h-[60px] object-contain"
/>
{iconUri ? (
<img
src={iconUri}
alt="Qwen Logo"
className="w-[60px] h-[60px] object-contain"
onError={(e) => {
// Fallback to a div with text if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = document.createElement('div');
fallback.className =
'w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold';
fallback.textContent = 'Q';
parent.appendChild(fallback);
}
}}
/>
) : (
<div className="w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold bg-gray-200 rounded">
Q
</div>
)}
<div className="text-center">
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]">
What to do first? Ask about this codebase or we can start writing
code.
{description}
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import {
PlanModeIcon,
CodeBracketsIcon,
HideContextIcon,
ThinkingIcon,
// ThinkingIcon, // Temporarily disabled
SlashCommandIcon,
LinkIcon,
ArrowUpIcon,
@@ -20,7 +20,7 @@ import {
import { CompletionMenu } from '../layout/CompletionMenu.js';
import type { CompletionItem } from '../../../types/completionItemTypes.js';
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
interface InputFormProps {
inputText: string;
@@ -92,7 +92,7 @@ export const InputForm: React.FC<InputFormProps> = ({
isWaitingForResponse,
isComposing,
editMode,
thinkingEnabled,
// thinkingEnabled, // Temporarily disabled
activeFileName,
activeSelection,
skipAutoActiveContext,
@@ -103,7 +103,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onSubmit,
onCancel,
onToggleEditMode,
onToggleThinking,
// onToggleThinking, // Temporarily disabled
onToggleSkipAutoActiveContext,
onShowCommandMenu,
onAttachContext,
@@ -113,6 +113,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onCompletionClose,
}) => {
const editModeInfo = getEditModeInfo(editMode);
const composerDisabled = isStreaming || isWaitingForResponse;
const handleKeyDown = (e: React.KeyboardEvent) => {
// ESC should cancel the current interaction (stop generation)
@@ -144,7 +145,7 @@ export const InputForm: React.FC<InputFormProps> = ({
return (
<div
className="p-1 px-4 pb-4"
className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
<div className="block">
@@ -179,10 +180,16 @@ export const InputForm: React.FC<InputFormProps> = ({
data-placeholder="Ask Qwen Code …"
// Use a data flag so CSS can show placeholder even if the browser
// inserts an invisible <br> into contentEditable (so :empty no longer matches)
data-empty={inputText.trim().length === 0 ? 'true' : 'false'}
data-empty={
inputText.replace(/\u200B/g, '').trim().length === 0
? 'true'
: 'false'
}
onInput={(e) => {
const target = e.target as HTMLDivElement;
onInputChange(target.textContent || '');
// Filter out zero-width space that we use to maintain height
const text = target.textContent?.replace(/\u200B/g, '') || '';
onInputChange(text);
}}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
@@ -236,15 +243,16 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* @yiliang114. closed temporarily */}
{/* Thinking button */}
<button
{/* <button
type="button"
className={`btn-icon-compact ${thinkingEnabled ? 'btn-icon-compact--active' : ''}`}
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={onToggleThinking}
>
<ThinkingIcon enabled={thinkingEnabled} />
</button>
</button> */}
{/* Command button */}
<button
@@ -280,7 +288,7 @@ export const InputForm: React.FC<InputFormProps> = ({
<button
type="submit"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
disabled={!inputText.trim()}
disabled={composerDisabled || !inputText.trim()}
>
<ArrowUpIcon />
</button>

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { generateIconUrl } from '../../utils/resourceUrl.js';
interface OnboardingPageProps {
onLogin: () => void;
}
export const Onboarding: React.FC<OnboardingPageProps> = ({ onLogin }) => {
const iconUri = generateIconUrl('icon.png');
return (
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
<div className="flex flex-col items-center gap-6">
{/* Application icon container */}
<div className="relative">
<img
src={iconUri}
alt="Qwen Code Logo"
className="w-[80px] h-[80px] object-contain"
/>
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-app-primary-foreground mb-2">
Welcome to Qwen Code
</h1>
<p className="text-app-secondary-foreground max-w-sm">
Unlock the power of AI to understand, navigate, and transform your
codebase faster than ever before.
</p>
</div>
<button
onClick={onLogin}
className="w-full px-4 py-3 bg-[#4f46e5] text-white font-medium rounded-lg shadow-sm hover:bg-[#4338ca] transition-colors duration-200"
>
Get Started with Qwen Code
</button>
</div>
</div>
</div>
);
};

View File

@@ -5,8 +5,10 @@
*/
import React from 'react';
import { groupSessionsByDate } from '../../utils/sessionGrouping.js';
import { getTimeAgo } from '../../utils/timeUtils.js';
import {
getTimeAgo,
groupSessionsByDate,
} from '../../utils/sessionGrouping.js';
import { SearchIcon } from '../icons/index.js';
interface SessionSelectorProps {

View File

@@ -75,7 +75,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
whiteSpace: 'normal',
}}
>
<MessageContent content={content} onFileClick={onFileClick} />
<MessageContent
content={content}
onFileClick={onFileClick}
enableFileLinks={false}
/>
</div>
</span>
</div>

View File

@@ -61,25 +61,6 @@ export const safeTitle = (title: unknown): string => {
return '';
};
/**
* Get icon emoji for a given tool kind
*/
export const getKindIcon = (kind: string): string => {
const kindMap: Record<string, string> = {
edit: '✏️',
write: '✏️',
read: '📖',
execute: '⚡',
fetch: '🌐',
delete: '🗑️',
move: '📦',
search: '🔍',
think: '💭',
diff: '📝',
};
return kindMap[kind.toLowerCase()] || '🔧';
};
/**
* Check if a tool call should be displayed
* Hides internal tool calls

View File

@@ -49,7 +49,6 @@ export class FileMessageHandler extends BaseMessageHandler {
break;
case 'openDiff':
console.log('[FileMessageHandler ===== ] openDiff called with:', data);
await this.handleOpenDiff(data);
break;

View File

@@ -11,7 +11,6 @@ import { SessionMessageHandler } from './SessionMessageHandler.js';
import { FileMessageHandler } from './FileMessageHandler.js';
import { EditorMessageHandler } from './EditorMessageHandler.js';
import { AuthMessageHandler } from './AuthMessageHandler.js';
import { SettingsMessageHandler } from './SettingsMessageHandler.js';
/**
* Message Router
@@ -63,20 +62,12 @@ export class MessageRouter {
sendToWebView,
);
const settingsHandler = new SettingsMessageHandler(
agentManager,
conversationStore,
currentConversationId,
sendToWebView,
);
// Register handlers in order of priority
this.handlers = [
this.sessionHandler,
fileHandler,
editorHandler,
this.authHandler,
settingsHandler,
];
}
@@ -159,11 +150,4 @@ export class MessageRouter {
appendStreamContent(chunk: string): void {
this.sessionHandler.appendStreamContent(chunk);
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.sessionHandler.getIsSavingCheckpoint();
}
}

View File

@@ -7,6 +7,7 @@
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ChatMessage } from '../../services/qwenAgentManager.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
/**
* Session message handler
@@ -14,7 +15,6 @@ import type { ChatMessage } from '../../services/qwenAgentManager.js';
*/
export class SessionMessageHandler extends BaseMessageHandler {
private currentStreamContent = '';
private isSavingCheckpoint = false;
private loginHandler: (() => Promise<void>) | null = null;
private isTitleSet = false; // Flag to track if title has been set
@@ -29,6 +29,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
'cancelStreaming',
// UI action: open a new chat tab (new WebviewPanel)
'openNewChatTab',
// Settings-related messages
'setApprovalMode',
].includes(messageType);
}
@@ -112,6 +114,14 @@ export class SessionMessageHandler extends BaseMessageHandler {
await this.handleCancelStreaming();
break;
case 'setApprovalMode':
await this.handleSetApprovalMode(
message.data as {
modeId?: ApprovalModeValue;
},
);
break;
default:
console.warn(
'[SessionMessageHandler] Unknown message type:',
@@ -143,10 +153,47 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
/**
* Check if saving checkpoint
* Prompt user to login and invoke the registered login handler/command.
* Returns true if a login was initiated.
*/
getIsSavingCheckpoint(): boolean {
return this.isSavingCheckpoint;
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();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
return true;
}
return false;
}
/**
* Prompt user to login or view offline. Returns 'login', 'offline', or 'dismiss'.
* When login is chosen, it triggers the login handler/command.
*/
private async promptLoginOrOffline(
message: string,
): Promise<'login' | 'offline' | 'dismiss'> {
const selection = await vscode.window.showWarningMessage(
message,
'Login Now',
'View Offline',
);
if (selection === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
return 'login';
}
if (selection === 'View Offline') {
return 'offline';
}
return 'dismiss';
}
/**
@@ -271,26 +318,37 @@ export class SessionMessageHandler extends BaseMessageHandler {
console.warn('[SessionMessageHandler] Agent not connected');
// Show non-modal notification with Login button
const result = await vscode.window.showWarningMessage(
'You need to login first to use Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
// Use login handler directly
if (this.loginHandler) {
await this.loginHandler();
} else {
// Fallback to command
vscode.window.showInformationMessage(
'Please wait while we connect to Qwen Code...',
);
await vscode.commands.executeCommand('qwen-code.login');
}
}
await this.promptLogin('You need to login first to use Qwen Code.');
return;
}
// Ensure an ACP session exists before sending prompt
if (!this.agentManager.currentSessionId) {
try {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
await this.agentManager.createNewSession(workingDir);
} catch (createErr) {
console.error(
'[SessionMessageHandler] Failed to create session before sending message:',
createErr,
);
const errorMsg =
createErr instanceof Error ? createErr.message : String(createErr);
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)')
) {
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
);
return;
}
vscode.window.showErrorMessage(`Failed to create session: ${errorMsg}`);
return;
}
}
// Send to agent
try {
this.resetStreamContent();
@@ -319,41 +377,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'streamEnd',
data: { timestamp: Date.now() },
});
// Auto-save checkpoint
if (this.currentConversationId) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
this.isSavingCheckpoint = true;
const result = await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
setTimeout(() => {
this.isSavingCheckpoint = false;
}, 2000);
if (result.success) {
console.log(
'[SessionMessageHandler] Checkpoint saved:',
result.tag,
);
}
} catch (error) {
console.error(
'[SessionMessageHandler] Checkpoint save failed:',
error,
);
this.isSavingCheckpoint = false;
}
}
} catch (error) {
console.error('[SessionMessageHandler] Error sending message:', error);
@@ -391,19 +414,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('Invalid token')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -428,38 +442,14 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Ensure connection (login) before creating a new session
if (!this.agentManager.isConnected) {
const result = await vscode.window.showWarningMessage(
const proceeded = await this.promptLogin(
'You need to login before creating a new session.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
} else {
if (!proceeded) {
return;
}
}
// Save current session before creating new one
if (this.currentConversationId && this.agentManager.isConnected) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
@@ -489,19 +479,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to create a new session.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -525,19 +506,11 @@ export class SessionMessageHandler extends BaseMessageHandler {
// If not connected yet, offer to login or view offline
if (!this.agentManager.isConnected) {
const selection = await vscode.window.showWarningMessage(
const choice = await this.promptLoginOrOffline(
'You are not logged in. Login now to fully restore this session, or view it offline.',
'Login Now',
'View Offline',
);
if (selection === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
} else if (selection === 'View Offline') {
if (choice === 'offline') {
// Show messages from local cache only
const messages =
await this.agentManager.getSessionMessages(sessionId);
@@ -550,33 +523,12 @@ export class SessionMessageHandler extends BaseMessageHandler {
'Showing cached session content. Login to interact with the AI.',
);
return;
} else {
} else if (choice !== 'login') {
// User dismissed; do nothing
return;
}
}
// Save current session before switching
if (
this.currentConversationId &&
this.currentConversationId !== sessionId &&
this.agentManager.isConnected
) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
// Get session details (includes cwd and filePath when using ACP)
let sessionDetails: Record<string, unknown> | null = null;
try {
@@ -637,19 +589,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to switch sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -706,19 +649,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
createErrorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to switch sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -755,19 +689,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to switch sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -819,19 +744,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to view sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -855,11 +771,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
throw new Error('No active conversation to save');
}
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
// Try ACP save first
try {
const response = await this.agentManager.saveSessionViaAcp(
@@ -883,19 +794,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to save sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -903,17 +805,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
});
return;
}
// Fallback to direct save
const response = await this.agentManager.saveSessionDirect(
messages,
tag,
);
this.sendToWebView({
type: 'saveSessionResponse',
data: response,
});
}
await this.handleGetQwenSessions();
@@ -931,19 +822,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to save sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -996,19 +878,11 @@ export class SessionMessageHandler extends BaseMessageHandler {
try {
// If not connected, offer to login or view offline
if (!this.agentManager.isConnected) {
const selection = await vscode.window.showWarningMessage(
const choice = await this.promptLoginOrOffline(
'You are not logged in. Login now to fully restore this session, or view it offline.',
'Login Now',
'View Offline',
);
if (selection === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
} else if (selection === 'View Offline') {
if (choice === 'offline') {
const messages =
await this.agentManager.getSessionMessages(sessionId);
this.currentConversationId = sessionId;
@@ -1020,7 +894,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
'Showing cached session content. Login to interact with the AI.',
);
return;
} else {
} else if (choice !== 'login') {
return;
}
}
@@ -1054,19 +928,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to resume sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -1074,20 +939,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
});
return;
}
// Fallback to direct load
const messages = await this.agentManager.loadSessionDirect(sessionId);
if (messages) {
this.currentConversationId = sessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
} else {
throw new Error('Failed to load session');
}
}
await this.handleGetQwenSessions();
@@ -1105,19 +956,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
const result = await vscode.window.showWarningMessage(
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to resume sessions.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
@@ -1131,4 +973,23 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
}
/**
* Set approval mode via agent (ACP session/set_mode)
*/
private async handleSetApprovalMode(data?: {
modeId?: ApprovalModeValue;
}): Promise<void> {
try {
const modeId = data?.modeId || 'default';
await this.agentManager.setApprovalModeFromUi(modeId);
// No explicit response needed; WebView listens for modeChanged
} catch (error) {
console.error('[SessionMessageHandler] Failed to set mode:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to set mode: ${error}` },
});
}
}
}

View File

@@ -1,101 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ApprovalModeValue } from '../../types/acpTypes.js';
/**
* Settings message handler
* Handles all settings-related messages
*/
export class SettingsMessageHandler extends BaseMessageHandler {
canHandle(messageType: string): boolean {
return ['openSettings', 'recheckCli', 'setApprovalMode'].includes(
messageType,
);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
switch (message.type) {
case 'openSettings':
await this.handleOpenSettings();
break;
case 'recheckCli':
await this.handleRecheckCli();
break;
case 'setApprovalMode':
await this.handleSetApprovalMode(
message.data as {
modeId?: ApprovalModeValue;
},
);
break;
default:
console.warn(
'[SettingsMessageHandler] Unknown message type:',
message.type,
);
break;
}
}
/**
* Open settings page
*/
private async handleOpenSettings(): Promise<void> {
try {
// Open settings in a side panel
await vscode.commands.executeCommand('workbench.action.openSettings', {
query: 'qwenCode',
});
} catch (error) {
console.error('[SettingsMessageHandler] Failed to open settings:', error);
vscode.window.showErrorMessage(`Failed to open settings: ${error}`);
}
}
/**
* Recheck CLI
*/
private async handleRecheckCli(): Promise<void> {
try {
await vscode.commands.executeCommand('qwenCode.recheckCli');
this.sendToWebView({
type: 'cliRechecked',
data: { success: true },
});
} catch (error) {
console.error('[SettingsMessageHandler] Failed to recheck CLI:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to recheck CLI: ${error}` },
});
}
}
/**
* Set approval mode via agent (ACP session/set_mode)
*/
private async handleSetApprovalMode(data?: {
modeId?: ApprovalModeValue;
}): Promise<void> {
try {
const modeId = data?.modeId || 'default';
await this.agentManager.setApprovalModeFromUi(modeId);
// No explicit response needed; WebView listens for modeChanged
} catch (error) {
console.error('[SettingsMessageHandler] Failed to set mode:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to set mode: ${error}` },
});
}
}
}

View File

@@ -34,6 +34,9 @@ export const useFileContext = (vscode: VSCodeAPI) => {
// Whether workspace files have been requested
const hasRequestedFilesRef = useRef(false);
// Last non-empty query to decide when to refetch full list
const lastQueryRef = useRef<string | undefined>(undefined);
// Search debounce timer
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -42,12 +45,10 @@ export const useFileContext = (vscode: VSCodeAPI) => {
*/
const requestWorkspaceFiles = useCallback(
(query?: string) => {
if (!hasRequestedFilesRef.current && !query) {
hasRequestedFilesRef.current = true;
}
const normalizedQuery = query?.trim();
// If there's a query, clear previous timer and set up debounce
if (query && query.length >= 1) {
if (normalizedQuery && normalizedQuery.length >= 1) {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
@@ -55,14 +56,23 @@ export const useFileContext = (vscode: VSCodeAPI) => {
searchTimerRef.current = setTimeout(() => {
vscode.postMessage({
type: 'getWorkspaceFiles',
data: { query },
data: { query: normalizedQuery },
});
}, 300);
lastQueryRef.current = normalizedQuery;
} else {
vscode.postMessage({
type: 'getWorkspaceFiles',
data: query ? { query } : {},
});
// For empty query, request once initially and whenever we are returning from a search
const shouldRequestFullList =
!hasRequestedFilesRef.current || lastQueryRef.current !== undefined;
if (shouldRequestFullList) {
lastQueryRef.current = undefined;
hasRequestedFilesRef.current = true;
vscode.postMessage({
type: 'getWorkspaceFiles',
data: {},
});
}
}
},
[vscode],

View File

@@ -131,12 +131,55 @@ export function useCompletionTrigger(
[getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM],
);
// Helper function to compare completion items arrays
const areItemsEqual = (
items1: CompletionItem[],
items2: CompletionItem[],
): boolean => {
if (items1.length !== items2.length) {
return false;
}
// Compare each item by stable fields (ignore non-deterministic props like icons)
for (let i = 0; i < items1.length; i++) {
const a = items1[i];
const b = items2[i];
if (a.id !== b.id) {
return false;
}
if (a.label !== b.label) {
return false;
}
if ((a.description ?? '') !== (b.description ?? '')) {
return false;
}
if (a.type !== b.type) {
return false;
}
if ((a.value ?? '') !== (b.value ?? '')) {
return false;
}
if ((a.path ?? '') !== (b.path ?? '')) {
return false;
}
}
return true;
};
const refreshCompletion = useCallback(async () => {
if (!state.isOpen || !state.triggerChar) {
return;
}
const items = await getCompletionItems(state.triggerChar, state.query);
setState((prev) => ({ ...prev, items }));
// Only update state if items have actually changed
setState((prev) => {
if (areItemsEqual(prev.items, items)) {
return prev;
}
return { ...prev, items };
});
}, [state.isOpen, state.triggerChar, state.query, getCompletionItems]);
useEffect(() => {

View File

@@ -14,6 +14,7 @@ interface UseMessageSubmitProps {
setInputText: (text: string) => void;
inputFieldRef: React.RefObject<HTMLDivElement>;
isStreaming: boolean;
isWaitingForResponse: boolean;
// When true, do NOT auto-attach the active editor file/selection to context
skipAutoActiveContext?: boolean;
@@ -40,6 +41,7 @@ export const useMessageSubmit = ({
setInputText,
inputFieldRef,
isStreaming,
isWaitingForResponse,
skipAutoActiveContext = false,
fileContext,
messageHandling,
@@ -48,7 +50,7 @@ export const useMessageSubmit = ({
(e: React.FormEvent) => {
e.preventDefault();
if (!inputText.trim() || isStreaming) {
if (!inputText.trim() || isStreaming || isWaitingForResponse) {
return;
}
@@ -56,7 +58,10 @@ export const useMessageSubmit = ({
if (inputText.trim() === '/login') {
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
// Use a zero-width space to maintain the height of the contentEditable element
inputFieldRef.current.textContent = '\u200B';
// Set the data-empty attribute to show the placeholder
inputFieldRef.current.setAttribute('data-empty', 'true');
}
vscode.postMessage({
type: 'login',
@@ -142,7 +147,10 @@ export const useMessageSubmit = ({
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
// Use a zero-width space to maintain the height of the contentEditable element
inputFieldRef.current.textContent = '\u200B';
// Set the data-empty attribute to show the placeholder
inputFieldRef.current.setAttribute('data-empty', 'true');
}
fileContext.clearFileReferences();
},
@@ -154,6 +162,7 @@ export const useMessageSubmit = ({
vscode,
fileContext,
skipAutoActiveContext,
isWaitingForResponse,
messageHandling,
],
);

View File

@@ -12,7 +12,7 @@ import type {
ToolCall as PermissionToolCall,
} from '../components/PermissionDrawer/PermissionRequest.js';
import type { ToolCallUpdate } from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js';
interface UseWebViewMessagesProps {
@@ -109,6 +109,8 @@ interface UseWebViewMessagesProps {
setInputText: (text: string) => void;
// Edit mode setter (maps ACP modes to UI modes)
setEditMode?: (mode: ApprovalModeValue) => void;
// Authentication state setter
setIsAuthenticated?: (authenticated: boolean | null) => void;
}
/**
@@ -126,6 +128,7 @@ export const useWebViewMessages = ({
inputFieldRef,
setInputText,
setEditMode,
setIsAuthenticated,
}: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host
const vscode = useVSCode();
@@ -141,6 +144,7 @@ export const useWebViewMessages = ({
clearToolCalls,
setPlanEntries,
handlePermissionRequest,
setIsAuthenticated,
});
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
@@ -185,6 +189,7 @@ export const useWebViewMessages = ({
clearToolCalls,
setPlanEntries,
handlePermissionRequest,
setIsAuthenticated,
};
});
@@ -216,6 +221,7 @@ export const useWebViewMessages = ({
}
break;
}
case 'loginSuccess': {
// Clear loading state and show a short assistant notice
handlers.messageHandling.clearWaitingForResponse();
@@ -224,43 +230,35 @@ export const useWebViewMessages = ({
content: 'Successfully logged in. You can continue chatting.',
timestamp: Date.now(),
});
// Set authentication state to true
handlers.setIsAuthenticated?.(true);
break;
}
// case 'cliNotInstalled': {
// // Show CLI not installed message
// const errorMsg =
// (message?.data?.error as string) ||
// 'Qwen Code CLI is not installed. Please install it to enable full functionality.';
case 'agentConnected': {
// Agent connected successfully; clear any pending spinner
handlers.messageHandling.clearWaitingForResponse();
// Set authentication state to true
handlers.setIsAuthenticated?.(true);
break;
}
// handlers.messageHandling.addMessage({
// role: 'assistant',
// content: `Qwen CLI is not installed. Please install it to enable full functionality.\n\nError: ${errorMsg}\n\nInstallation instructions:\n1. Install via npm:\n npm install -g @qwen-code/qwen-code@latest\n\n2. After installation, reload VS Code or restart the extension.`,
// timestamp: Date.now(),
// });
// break;
// }
case 'agentConnectionError': {
// Agent connection failed; surface the error and unblock the UI
handlers.messageHandling.clearWaitingForResponse();
const errorMsg =
(message?.data?.message as string) ||
'Failed to connect to Qwen agent.';
// case 'agentConnected': {
// // Agent connected successfully
// handlers.messageHandling.clearWaitingForResponse();
// break;
// }
// case 'agentConnectionError': {
// // Agent connection failed
// handlers.messageHandling.clearWaitingForResponse();
// const errorMsg =
// (message?.data?.message as string) ||
// 'Failed to connect to Qwen agent.';
// handlers.messageHandling.addMessage({
// role: 'assistant',
// content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`,
// timestamp: Date.now(),
// });
// break;
// }
handlers.messageHandling.addMessage({
role: 'assistant',
content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`,
timestamp: Date.now(),
});
// Set authentication state to false
handlers.setIsAuthenticated?.(false);
break;
}
case 'loginError': {
// Clear loading state and show error notice
@@ -273,6 +271,20 @@ export const useWebViewMessages = ({
content: errorMsg,
timestamp: Date.now(),
});
// Set authentication state to false
handlers.setIsAuthenticated?.(false);
break;
}
case 'authState': {
const state = (
message?.data as { authenticated?: boolean | null } | undefined
)?.authenticated;
if (typeof state === 'boolean') {
handlers.setIsAuthenticated?.(state);
} else {
handlers.setIsAuthenticated?.(null);
}
break;
}
@@ -338,30 +350,42 @@ export const useWebViewMessages = ({
}
case 'streamEnd': {
// Always end local streaming state and collapse any thoughts
// Always end local streaming state and clear thinking state
handlers.messageHandling.endStreaming();
handlers.messageHandling.clearThinking();
// If the stream ended due to explicit user cancel, proactively
// clear the waiting indicator and reset any tracked exec calls.
// This avoids the UI being stuck with the Stop button visible
// after rejecting a permission request.
// If stream ended due to explicit user cancellation, proactively clear
// waiting indicator and reset tracked execution calls.
// This avoids UI getting stuck with Stop button visible after
// rejecting a permission request.
try {
const reason = (
(message.data as { reason?: string } | undefined)?.reason || ''
).toLowerCase();
if (reason === 'user_cancelled') {
/**
* Handle different types of stream end reasons:
* - 'user_cancelled': User explicitly cancelled operation
* - 'cancelled': General cancellation
* For these cases, immediately clear all active states
*/
if (reason === 'user_cancelled' || reason === 'cancelled') {
// Clear active execution tool call tracking, reset state
activeExecToolCallsRef.current.clear();
// Clear waiting response state to ensure UI returns to normal
handlers.messageHandling.clearWaitingForResponse();
break;
}
} catch (_error) {
// best-effort
// Best-effort handling, errors don't affect main flow
}
// Otherwise, clear the generic waiting indicator only if there are
// no active long-running tool calls. If there are still active
// execute/bash/command calls, keep the hint visible.
/**
* For other types of stream end (non-user cancellation):
* Only clear generic waiting indicator when there are no active
* long-running tool calls. If there are still active execute/bash/command
* calls, keep the hint visible.
*/
if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse();
}
@@ -562,15 +586,21 @@ export const useWebViewMessages = ({
// While long-running tools (e.g., execute/bash/command) are in progress,
// surface a lightweight loading indicator and expose the Stop button.
try {
const id = (toolCallData.toolCallId || '').toString();
const kind = (toolCallData.kind || '').toString().toLowerCase();
const isExec =
const isExecKind =
kind === 'execute' || kind === 'bash' || kind === 'command';
// CLI sometimes omits kind in tool_call_update payloads; fall back to
// whether we've already tracked this ID as an exec tool.
const wasTrackedExec = activeExecToolCallsRef.current.has(id);
const isExec = isExecKind || wasTrackedExec;
if (isExec) {
const id = (toolCallData.toolCallId || '').toString();
if (!isExec || !id) {
break;
}
// Maintain the active set by status
if (status === 'pending' || status === 'in_progress') {
if (status === 'pending' || status === 'in_progress') {
if (isExecKind) {
activeExecToolCallsRef.current.add(id);
// Build a helpful hint from rawInput
@@ -584,14 +614,14 @@ export const useWebViewMessages = ({
}
const hint = cmd ? `Running: ${cmd}` : 'Running command...';
handlers.messageHandling.setWaitingForResponse(hint);
} else if (status === 'completed' || status === 'failed') {
activeExecToolCallsRef.current.delete(id);
}
} else if (status === 'completed' || status === 'failed') {
activeExecToolCallsRef.current.delete(id);
}
// If no active exec tool remains, clear the waiting message.
if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse();
}
// If no active exec tool remains, clear the waiting message.
if (activeExecToolCallsRef.current.size === 0) {
handlers.messageHandling.clearWaitingForResponse();
}
} catch (_error) {
// Best-effort UI hint; ignore errors

View File

@@ -5,7 +5,6 @@
*/
/* Import component styles */
@import '../components/messages/Assistant/AssistantMessage.css';
@import './timeline.css';
@import '../components/messages/MarkdownRenderer/MarkdownRenderer.css';

View File

@@ -51,8 +51,7 @@
.composer-form:focus-within {
/* match existing highlight behavior */
border-color: var(--app-input-highlight);
box-shadow: 0 1px 2px
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
}
/* Composer: input editable area */
@@ -67,7 +66,7 @@
The data attribute is needed because some browsers insert a <br> in
contentEditable, which breaks :empty matching. */
.composer-input:empty:before,
.composer-input[data-empty='true']::before {
.composer-input[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground);
pointer-events: none;
@@ -81,7 +80,7 @@
outline: none;
}
.composer-input:disabled,
.composer-input[contenteditable='false'] {
.composer-input[contenteditable="false"] {
color: #999;
cursor: not-allowed;
}
@@ -111,7 +110,7 @@
}
.btn-text-compact > svg {
height: 1em;
width: 1em;
width: 1em;
flex-shrink: 0;
}
.btn-text-compact > span {

View File

@@ -88,6 +88,22 @@
z-index: 0;
}
/* Single-item AI sequence (both a start and an end): hide the connector entirely */
.qwen-message.message-item:not(.user-message-container):is(
:first-child,
.user-message-container
+ .qwen-message.message-item:not(.user-message-container),
.chat-messages
> :not(.qwen-message.message-item)
+ .qwen-message.message-item:not(.user-message-container)
):is(
:has(+ .user-message-container),
:has(+ :not(.qwen-message.message-item)),
:last-child
)::after {
display: none;
}
/* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */
.qwen-message.message-item:not(.user-message-container):first-child::after,
.user-message-container + .qwen-message.message-item:not(.user-message-container)::after,
@@ -123,4 +139,4 @@
position: relative;
padding-top: 8px;
padding-bottom: 8px;
}
}

Some files were not shown because too many files have changed in this diff Show More