diff --git a/.vscode/launch.json b/.vscode/launch.json index 0ae4f1b1..bab4f22e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 58709bc9..e0ee4730 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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": [] } ] } diff --git a/docs/cli/language.md b/docs/cli/language.md index 7fb1e7f0..e4d403f0 100644 --- a/docs/cli/language.md +++ b/docs/cli/language.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 14da548b..29429f81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7b842b10..661afeda 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index ab98e974..685c6e90 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 2ef78bbd..84ba5ff5 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -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 { + 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; sessionUpdate(params: schema.SessionNotification): Promise; + authenticateUpdate(params: schema.AuthenticateUpdate): Promise; writeTextFile( params: schema.WriteTextFileRequest, ): Promise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index fc3c4ccc..91ce53cb 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -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 { 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 { 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, + ); } } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index ac754318..a557c519 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -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; export type AuthenticateRequest = z.infer; -export type AuthenticateResponse = z.infer; - export type NewSessionResponse = z.infer; export type LoadSessionResponse = z.infer; @@ -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; 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, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3212996d..ab4f087d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 { 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 { // 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)['channel'] = 'ACP'; + } + return result as unknown as CliArgs; } @@ -983,6 +995,7 @@ export async function loadCliConfig( output: { format: outputSettingsFormat, }, + channel: argv.channel, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbb..439bc5d9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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: { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f602d17d..205a3d88 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -485,6 +485,7 @@ describe('gemini.tsx main function kitty protocol', () => { excludeTools: undefined, authType: undefined, maxSessionTurns: undefined, + channel: undefined, }); await main(); diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 2cad8dec..7436336b 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -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 } diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c2217757..dec11869 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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:', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js new file mode 100644 index 00000000..009578be --- /dev/null +++ b/packages/cli/src/i18n/locales/ru.js @@ -0,0 +1,1121 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Русский перевод для Qwen Code CLI +// Ключ служит одновременно ключом перевода и текстом по умолчанию + +export default { + // ============================================================================ + // Справка / Компоненты интерфейса + // ============================================================================ + 'Basics:': 'Основы:', + 'Add context': 'Добавить контекст', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Используйте {{symbol}} для добавления файлов в контекст (например, {{example}}) для выбора конкретных файлов или папок).', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Режим терминала', + 'YOLO mode': 'Режим YOLO', + 'plan mode': 'Режим планирования', + 'auto-accept edits': 'Режим принятия правок', + 'Accepting edits': 'Принятие правок', + '(shift + tab to cycle)': '(shift + tab для переключения)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Выполняйте команды терминала через {{symbol}} (например, {{example1}}) или используйте естественный язык (например, {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'Команды:', + 'shell command': 'команда терминала', + 'Model Context Protocol command (from external servers)': + 'Команда Model Context Protocol (из внешних серверов)', + 'Keyboard Shortcuts:': 'Горячие клавиши:', + 'Jump through words in the input': 'Переход по словам во вводе', + 'Close dialogs, cancel requests, or quit application': + 'Закрыть диалоги, отменить запросы или выйти из приложения', + 'New line': 'Новая строка', + 'New line (Alt+Enter works for certain linux distros)': + 'Новая строка (Alt+Enter работает только в некоторых дистрибутивах Linux)', + 'Clear the screen': 'Очистить экран', + 'Open input in external editor': 'Открыть ввод во внешнем редакторе', + 'Send message': 'Отправить сообщение', + 'Initializing...': 'Инициализация...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Подключение к MCP-серверам... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.", + 'Cancel operation / Clear input (double press)': + 'Отменить операцию / Очистить ввод (двойное нажатие)', + 'Cycle approval modes': 'Переключение режимов подтверждения', + 'Cycle through your prompt history': 'Пролистать историю запросов', + 'For a full list of shortcuts, see {{docPath}}': + 'Полный список горячих клавиш см. в {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'Справка по Qwen Code', + 'show version info': 'Просмотр информации о версии', + 'submit a bug report': 'Отправка отчёта об ошибке', + 'About Qwen Code': 'Об Qwen Code', + + // ============================================================================ + // Поля системной информации + // ============================================================================ + 'CLI Version': 'Версия CLI', + 'Git Commit': 'Git-коммит', + Model: 'Модель', + Sandbox: 'Песочница', + 'OS Platform': 'Платформа ОС', + 'OS Arch': 'Архитектура ОС', + 'OS Release': 'Версия ОС', + 'Node.js Version': 'Версия Node.js', + 'NPM Version': 'Версия NPM', + 'Session ID': 'ID сессии', + 'Auth Method': 'Метод авторизации', + 'Base URL': 'Базовый URL', + 'Memory Usage': 'Использование памяти', + 'IDE Client': 'Клиент IDE', + + // ============================================================================ + // Команды - Общие + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Анализ проекта и создание адаптированного файла QWEN.md', + 'list available Qwen Code tools. Usage: /tools [desc]': + 'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]', + 'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:', + 'No tools available': 'Нет доступных инструментов', + 'View or change the approval mode for tool usage': + 'Просмотр или изменение режима подтверждения для использования инструментов', + 'View or change the language setting': + 'Просмотр или изменение настроек языка', + 'change the theme': 'Изменение темы', + 'Select Theme': 'Выбор темы', + Preview: 'Предпросмотр', + '(Use Enter to select, Tab to configure scope)': + '(Enter для выбора, Tab для настройки области)', + '(Use Enter to apply scope, Tab to select theme)': + '(Enter для применения области, Tab для выбора темы)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Настройка темы недоступна из-за переменной окружения NO_COLOR.', + 'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Тема "{{themeName}}" не найдена в выбранной области.', + 'clear the screen and conversation history': + 'Очистка экрана и истории диалога', + 'Compresses the context by replacing it with a summary.': + 'Сжатие контекста заменой на краткую сводку', + 'open full Qwen Code documentation in your browser': + 'Открытие полной документации Qwen Code в браузере', + 'Configuration not available.': 'Конфигурация недоступна.', + 'change the auth method': 'Изменение метода авторизации', + 'Copy the last result or code snippet to clipboard': + 'Копирование последнего результата или фрагмента кода в буфер обмена', + + // ============================================================================ + // Команды - Агенты + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Управление подагентами для делегирования специализированных задач', + 'Manage existing subagents (view, edit, delete).': + 'Управление существующими подагентами (просмотр, правка, удаление)', + 'Create a new subagent with guided setup.': + 'Создание нового подагента с пошаговой настройкой', + + // ============================================================================ + // Агенты - Диалог управления + // ============================================================================ + Agents: 'Агенты', + 'Choose Action': 'Выберите действие', + 'Edit {{name}}': 'Редактировать {{name}}', + 'Edit Tools: {{name}}': 'Редактировать инструменты: {{name}}', + 'Edit Color: {{name}}': 'Редактировать цвет: {{name}}', + 'Delete {{name}}': 'Удалить {{name}}', + 'Unknown Step': 'Неизвестный шаг', + 'Esc to close': 'Esc для закрытия', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter для выбора, ↑↓ для навигации, Esc для закрытия', + 'Esc to go back': 'Esc для возврата', + 'Enter to confirm, Esc to cancel': 'Enter для подтверждения, Esc для отмены', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter для выбора, ↑↓ для навигации, Esc для возврата', + 'Invalid step: {{step}}': 'Неверный шаг: {{step}}', + 'No subagents found.': 'Подагенты не найдены.', + "Use '/agents create' to create your first subagent.": + "Используйте '/agents create' для создания первого подагента.", + '(built-in)': '(встроенный)', + '(overridden by project level agent)': + '(переопределен агентом уровня проекта)', + 'Project Level ({{path}})': 'Уровень проекта ({{path}})', + 'User Level ({{path}})': 'Уровень пользователя ({{path}})', + 'Built-in Agents': 'Встроенные агенты', + 'Using: {{count}} agents': 'Используется: {{count}} агент(ов)', + 'View Agent': 'Просмотреть агента', + 'Edit Agent': 'Редактировать агента', + 'Delete Agent': 'Удалить агента', + Back: 'Назад', + 'No agent selected': 'Агент не выбран', + 'File Path: ': 'Путь к файлу: ', + 'Tools: ': 'Инструменты: ', + 'Color: ': 'Цвет: ', + 'Description:': 'Описание:', + 'System Prompt:': 'Системный промпт:', + 'Open in editor': 'Открыть в редакторе', + 'Edit tools': 'Редактировать инструменты', + 'Edit color': 'Редактировать цвет', + '❌ Error:': '❌ Ошибка:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Вы уверены, что хотите удалить агента "{{name}}"?', + // ============================================================================ + // Агенты - Мастер создания + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Уровень проекта (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'Уровень пользователя (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Подагент успешно создан!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Подагент "{{name}}" сохранен на уровне {{level}}.', + 'Name: ': 'Имя: ', + 'Location: ': 'Расположение: ', + '❌ Error saving subagent:': '❌ Ошибка сохранения подагента:', + 'Warnings:': 'Предупреждения:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Имя "{{name}}" уже существует на уровне {{level}} - существующий подагент будет перезаписан', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Имя "{{name}}" существует на уровне пользователя - уровень проекта будет иметь приоритет', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Имя "{{name}}" существует на уровне проекта - существующий подагент будет иметь приоритет', + 'Description is over {{length}} characters': + 'Описание превышает {{length}} символов', + 'System prompt is over {{length}} characters': + 'Системный промпт превышает {{length}} символов', + // Агенты - Шаги мастера создания + 'Step {{n}}: Choose Location': 'Шаг {{n}}: Выберите расположение', + 'Step {{n}}: Choose Generation Method': 'Шаг {{n}}: Выберите метод генерации', + 'Generate with Qwen Code (Recommended)': + 'Сгенерировать с помощью Qwen Code (Рекомендуется)', + 'Manual Creation': 'Ручное создание', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Опишите, что должен делать этот подагент и когда его следует использовать. (Будьте подробны для лучших результатов)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'например, Экспертный ревьювер кода, проверяющий код на соответствие лучшим практикам...', + 'Generating subagent configuration...': 'Генерация конфигурации подагента...', + 'Failed to generate subagent: {{error}}': + 'Не удалось сгенерировать подагента: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Шаг {{n}}: Опишите подагента', + 'Step {{n}}: Enter Subagent Name': 'Шаг {{n}}: Введите имя подагента', + 'Step {{n}}: Enter System Prompt': 'Шаг {{n}}: Введите системный промпт', + 'Step {{n}}: Enter Description': 'Шаг {{n}}: Введите описание', + // Агенты - Выбор инструментов + 'Step {{n}}: Select Tools': 'Шаг {{n}}: Выберите инструменты', + 'All Tools (Default)': 'Все инструменты (по умолчанию)', + 'All Tools': 'Все инструменты', + 'Read-only Tools': 'Инструменты только для чтения', + 'Read & Edit Tools': 'Инструменты для чтения и редактирования', + 'Read & Edit & Execution Tools': + 'Инструменты для чтения, редактирования и выполнения', + 'All tools selected, including MCP tools': + 'Все инструменты выбраны, включая инструменты MCP', + 'Selected tools:': 'Выбранные инструменты:', + 'Read-only tools:': 'Инструменты только для чтения:', + 'Edit tools:': 'Инструменты редактирования:', + 'Execution tools:': 'Инструменты выполнения:', + 'Step {{n}}: Choose Background Color': 'Шаг {{n}}: Выберите цвет фона', + 'Step {{n}}: Confirm and Save': 'Шаг {{n}}: Подтвердите и сохраните', + // Агенты - Навигация и инструкции + 'Esc to cancel': 'Esc для отмены', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter для сохранения, e для сохранения и редактирования, Esc для возврата', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter для продолжения, {{navigation}}Esc для {{action}}', + cancel: 'отмены', + 'go back': 'возврата', + '↑↓ to navigate, ': '↑↓ для навигации, ', + 'Enter a clear, unique name for this subagent.': + 'Введите четкое, уникальное имя для этого подагента.', + 'e.g., Code Reviewer': 'например, Ревьювер кода', + 'Name cannot be empty.': 'Имя не может быть пустым.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'Напишите системный промпт, определяющий поведение подагента. Будьте подробны для лучших результатов.', + 'e.g., You are an expert code reviewer...': + 'например, Вы экспертный ревьювер кода...', + 'System prompt cannot be empty.': 'Системный промпт не может быть пустым.', + 'Describe when and how this subagent should be used.': + 'Опишите, когда и как следует использовать этого подагента.', + 'e.g., Reviews code for best practices and potential bugs.': + 'например, Проверяет код на соответствие лучшим практикам и потенциальные ошибки.', + 'Description cannot be empty.': 'Описание не может быть пустым.', + 'Failed to launch editor: {{error}}': + 'Не удалось запустить редактор: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Не удалось сохранить и отредактировать подагента: {{error}}', + + // ============================================================================ + // Команды - Общие (продолжение) + // ============================================================================ + 'View and edit Qwen Code settings': 'Просмотр и изменение настроек Qwen Code', + Settings: 'Настройки', + '(Use Enter to select{{tabText}})': '(Enter для выбора{{tabText}})', + ', Tab to change focus': ', Tab для смены фокуса', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.', + + // ============================================================================ + // Метки настроек + // ============================================================================ + 'Vim Mode': 'Режим Vim', + 'Disable Auto Update': 'Отключить автообновление', + 'Enable Prompt Completion': 'Включить автодополнение промптов', + 'Debug Keystroke Logging': 'Логирование нажатий клавиш для отладки', + Language: 'Язык', + 'Output Format': 'Формат вывода', + 'Hide Window Title': 'Скрыть заголовок окна', + 'Show Status in Title': 'Показывать статус в заголовке', + 'Hide Tips': 'Скрыть подсказки', + 'Hide Banner': 'Скрыть баннер', + 'Hide Context Summary': 'Скрыть сводку контекста', + 'Hide CWD': 'Скрыть текущую директорию', + 'Hide Sandbox Status': 'Скрыть статус песочницы', + 'Hide Model Info': 'Скрыть информацию о модели', + 'Hide Footer': 'Скрыть нижний колонтитул', + 'Show Memory Usage': 'Показывать использование памяти', + 'Show Line Numbers': 'Показывать номера строк', + 'Show Citations': 'Показывать цитаты', + 'Custom Witty Phrases': 'Пользовательские остроумные фразы', + 'Enable Welcome Back': 'Включить приветствие при возврате', + 'Disable Loading Phrases': 'Отключить фразы при загрузке', + 'Screen Reader Mode': 'Режим программы чтения с экрана', + 'IDE Mode': 'Режим IDE', + 'Max Session Turns': 'Макс. количество ходов сессии', + 'Skip Next Speaker Check': 'Пропустить проверку следующего говорящего', + 'Skip Loop Detection': 'Пропустить обнаружение циклов', + 'Skip Startup Context': 'Пропустить начальный контекст', + 'Enable OpenAI Logging': 'Включить логирование OpenAI', + 'OpenAI Logging Directory': 'Директория логов OpenAI', + Timeout: 'Таймаут', + 'Max Retries': 'Макс. количество попыток', + 'Disable Cache Control': 'Отключить управление кэшем', + 'Memory Discovery Max Dirs': 'Макс. директорий для поиска в памяти', + 'Load Memory From Include Directories': + 'Загружать память из включенных директорий', + 'Respect .gitignore': 'Учитывать .gitignore', + 'Respect .qwenignore': 'Учитывать .qwenignore', + 'Enable Recursive File Search': 'Включить рекурсивный поиск файлов', + 'Disable Fuzzy Search': 'Отключить нечеткий поиск', + 'Enable Interactive Shell': 'Включить интерактивный терминал', + 'Show Color': 'Показывать цвета', + 'Auto Accept': 'Автоподтверждение', + 'Use Ripgrep': 'Использовать Ripgrep', + 'Use Builtin Ripgrep': 'Использовать встроенный Ripgrep', + 'Enable Tool Output Truncation': 'Включить обрезку вывода инструментов', + 'Tool Output Truncation Threshold': 'Порог обрезки вывода инструментов', + 'Tool Output Truncation Lines': 'Лимит строк вывода инструментов', + 'Folder Trust': 'Доверие к папке', + 'Vision Model Preview': 'Визуальная модель (предпросмотр)', + // Варианты перечислений настроек + 'Auto (detect from system)': 'Авто (определить из системы)', + Text: 'Текст', + JSON: 'JSON', + Plan: 'План', + Default: 'По умолчанию', + 'Auto Edit': 'Авторедактирование', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'Включение/выключение режима vim', + 'check session stats. Usage: /stats [model|tools]': + 'Просмотр статистики сессии. Использование: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Показать статистику использования модели.', + 'Show tool-specific usage statistics.': + 'Показать статистику использования инструментов.', + 'exit the cli': 'Выход из CLI', + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth', + 'Manage workspace directories': + 'Управление директориями рабочего пространства', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Добавить директории в рабочее пространство. Используйте запятую для разделения путей', + 'Show all directories in the workspace': + 'Показать все директории в рабочем пространстве', + 'set external editor preference': + 'Установка предпочитаемого внешнего редактора', + 'Manage extensions': 'Управление расширениями', + 'List active extensions': 'Показать активные расширения', + 'Update extensions. Usage: update |--all': + 'Обновить расширения. Использование: update |--all', + 'manage IDE integration': 'Управление интеграцией с IDE', + 'check status of IDE integration': 'Проверить статус интеграции с IDE', + 'install required IDE companion for {{ideName}}': + 'Установить необходимый компаньон IDE для {{ideName}}', + 'enable IDE integration': 'Включение интеграции с IDE', + 'disable IDE integration': 'Отключение интеграции с IDE', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'Интеграция с IDE не поддерживается в вашем окружении. Для использования этой функции запустите Qwen Code в одной из поддерживаемых IDE: VS Code или форках VS Code.', + 'Set up GitHub Actions': 'Настройка GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Настройка привязки клавиш терминала для многострочного ввода (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Пожалуйста, перезапустите терминал для применения изменений.', + 'Failed to configure terminal: {{error}}': + 'Не удалось настроить терминал: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Не удалось определить путь конфигурации {{terminalName}} в Windows: переменная окружения APPDATA не установлена.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json существует, но не является корректным массивом JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'File: {{file}}': 'Файл: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Не удалось разобрать {{terminalName}} keybindings.json. Файл содержит некорректный JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'Error: {{error}}': 'Ошибка: {{error}}', + 'Shift+Enter binding already exists': 'Привязка Shift+Enter уже существует', + 'Ctrl+Enter binding already exists': 'Привязка Ctrl+Enter уже существует', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Обнаружены существующие привязки клавиш. Не будут изменены во избежание конфликтов.', + 'Please check and modify manually if needed: {{file}}': + 'Пожалуйста, проверьте и измените вручную при необходимости: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Добавлены привязки Shift+Enter и Ctrl+Enter для {{terminalName}}.', + 'Modified: {{file}}': 'Изменено: {{file}}', + '{{terminalName}} keybindings already configured.': + 'Привязки клавиш {{terminalName}} уже настроены.', + 'Failed to configure {{terminalName}}.': + 'Не удалось настроить {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Терминал "{{terminal}}" еще не поддерживается.', + + // ============================================================================ + // Команды - Язык + // ============================================================================ + 'Invalid language. Available: en-US, zh-CN': + 'Неверный язык. Доступны: en-US, zh-CN, ru-RU', + 'Language subcommands do not accept additional arguments.': + 'Подкоманды языка не принимают дополнительных аргументов.', + 'Current UI language: {{lang}}': 'Текущий язык интерфейса: {{lang}}', + 'Current LLM output language: {{lang}}': 'Текущий язык вывода LLM: {{lang}}', + 'LLM output language not set': 'Язык вывода LLM не установлен', + 'Set UI language': 'Установка языка интерфейса', + 'Set LLM output language': 'Установка языка вывода LLM', + 'Usage: /language ui [zh-CN|en-US]': + 'Использование: /language ui [zh-CN|en-US|ru-RU]', + 'Usage: /language output ': 'Использование: /language output ', + 'Example: /language output 中文': 'Пример: /language output 中文', + 'Example: /language output English': 'Пример: /language output English', + 'Example: /language output 日本語': 'Пример: /language output 日本語', + 'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'Файл правил языка вывода LLM создан в {{path}}', + 'Please restart the application for the changes to take effect.': + 'Пожалуйста, перезапустите приложение для применения изменений.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Не удалось создать файл правил языка вывода LLM: {{error}}', + 'Invalid command. Available subcommands:': + 'Неверная команда. Доступные подкоманды:', + 'Available subcommands:': 'Доступные подкоманды:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.', + 'Available options:': 'Доступные варианты:', + ' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский', + ' - en-US: English': ' - en-US: Английский', + ' - ru-RU: Russian': ' - ru-RU: Русский', + 'Set UI language to Simplified Chinese (zh-CN)': + 'Установить язык интерфейса на упрощенный китайский (zh-CN)', + 'Set UI language to English (en-US)': + 'Установить язык интерфейса на английский (en-US)', + + // ============================================================================ + // Команды - Режим подтверждения + // ============================================================================ + 'Approval Mode': 'Режим подтверждения', + 'Current approval mode: {{mode}}': 'Текущий режим подтверждения: {{mode}}', + 'Available approval modes:': 'Доступные режимы подтверждения:', + 'Approval mode changed to: {{mode}}': + 'Режим подтверждения изменен на: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Использование: /approval-mode [--session|--user|--project]', + 'Scope subcommands do not accept additional arguments.': + 'Подкоманды области не принимают дополнительных аргументов.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Режим планирования - только анализ, без изменения файлов или выполнения команд', + 'Default mode - Require approval for file edits or shell commands': + 'Режим по умолчанию - требуется подтверждение для редактирования файлов или команд терминала', + 'Auto-edit mode - Automatically approve file edits': + 'Режим авторедактирования - автоматическое подтверждение изменений файлов', + 'YOLO mode - Automatically approve all tools': + 'Режим YOLO - автоматическое подтверждение всех инструментов', + '{{mode}} mode': 'Режим {{mode}}', + 'Settings service is not available; unable to persist the approval mode.': + 'Служба настроек недоступна; невозможно сохранить режим подтверждения.', + 'Failed to save approval mode: {{error}}': + 'Не удалось сохранить режим подтверждения: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Не удалось изменить режим подтверждения: {{error}}', + 'Apply to current session only (temporary)': + 'Применить только к текущей сессии (временно)', + 'Persist for this project/workspace': + 'Сохранить для этого проекта/рабочего пространства', + 'Persist for this user on this machine': + 'Сохранить для этого пользователя на этой машине', + 'Analyze only, do not modify files or execute commands': + 'Только анализ, без изменения файлов или выполнения команд', + 'Require approval for file edits or shell commands': + 'Требуется подтверждение для редактирования файлов или команд терминала', + 'Automatically approve file edits': + 'Автоматически подтверждать изменения файлов', + 'Automatically approve all tools': + 'Автоматически подтверждать все инструменты', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Режим подтверждения рабочего пространства существует и имеет приоритет. Изменение на уровне пользователя не будет иметь эффекта.', + '(Use Enter to select, Tab to change focus)': + '(Enter для выбора, Tab для смены фокуса)', + 'Apply To': 'Применить к', + 'User Settings': 'Настройки пользователя', + 'Workspace Settings': 'Настройки рабочего пространства', + + // ============================================================================ + // Команды - Память + // ============================================================================ + 'Commands for interacting with memory.': + 'Команды для взаимодействия с памятью', + 'Show the current memory contents.': 'Показать текущее содержимое памяти.', + 'Show project-level memory contents.': 'Показать память уровня проекта.', + 'Show global memory contents.': 'Показать глобальную память.', + 'Add content to project-level memory.': + 'Добавить содержимое в память уровня проекта.', + 'Add content to global memory.': 'Добавить содержимое в глобальную память.', + 'Refresh the memory from the source.': 'Обновить память из источника.', + 'Usage: /memory add --project ': + 'Использование: /memory add --project <текст для запоминания>', + 'Usage: /memory add --global ': + 'Использование: /memory add --global <текст для запоминания>', + 'Attempting to save to project memory: "{{text}}"': + 'Попытка сохранить в память проекта: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Попытка сохранить в глобальную память: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Текущее содержимое памяти из {{count}} файла(ов):', + 'Memory is currently empty.': 'Память в настоящее время пуста.', + 'Project memory file not found or is currently empty.': + 'Файл памяти проекта не найден или в настоящее время пуст.', + 'Global memory file not found or is currently empty.': + 'Файл глобальной памяти не найден или в настоящее время пуст.', + 'Global memory is currently empty.': + 'Глобальная память в настоящее время пуста.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Содержимое глобальной памяти:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Содержимое памяти проекта из {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': + 'Память проекта в настоящее время пуста.', + 'Refreshing memory from source files...': + 'Обновление памяти из исходных файлов...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Добавить содержимое в память. Используйте --global для глобальной памяти или --project для памяти проекта.', + 'Usage: /memory add [--global|--project] ': + 'Использование: /memory add [--global|--project] <текст для запоминания>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Попытка сохранить в память {{scope}}: "{{fact}}"', + + // ============================================================================ + // Команды - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Авторизоваться на MCP-сервере с поддержкой OAuth', + 'List configured MCP servers and tools': + 'Просмотр настроенных MCP-серверов и инструментов', + 'Restarts MCP servers.': 'Перезапустить MCP-серверы.', + 'Config not loaded.': 'Конфигурация не загружена.', + 'Could not retrieve tool registry.': + 'Не удалось получить реестр инструментов.', + 'No MCP servers configured with OAuth authentication.': + 'Нет MCP-серверов, настроенных с авторизацией OAuth.', + 'MCP servers with OAuth authentication:': 'MCP-серверы с авторизацией OAuth:', + 'Use /mcp auth to authenticate.': + 'Используйте /mcp auth <имя-сервера> для авторизации.', + "MCP server '{{name}}' not found.": "MCP-сервер '{{name}}' не найден.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Успешно авторизовано и обновлены инструменты для '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "Повторное обнаружение инструментов от '{{name}}'...", + + // ============================================================================ + // Команды - Чат + // ============================================================================ + 'Manage conversation history.': 'Управление историей диалогов.', + 'List saved conversation checkpoints': + 'Показать сохраненные точки восстановления диалога', + 'No saved conversation checkpoints found.': + 'Не найдено сохраненных точек восстановления диалога.', + 'List of saved conversations:': 'Список сохраненных диалогов:', + 'Note: Newest last, oldest first': + 'Примечание: новые последними, старые первыми', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Сохранить текущий диалог как точку восстановления. Использование: /chat save <тег>', + 'Missing tag. Usage: /chat save ': + 'Отсутствует тег. Использование: /chat save <тег>', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Удалить точку восстановления диалога. Использование: /chat delete <тег>', + 'Missing tag. Usage: /chat delete ': + 'Отсутствует тег. Использование: /chat delete <тег>', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Точка восстановления диалога '{{tag}}' удалена.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Ошибка: точка восстановления с тегом '{{tag}}' не найдена.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Возобновить диалог из точки восстановления. Использование: /chat resume <тег>', + 'Missing tag. Usage: /chat resume ': + 'Отсутствует тег. Использование: /chat resume <тег>', + 'No saved checkpoint found with tag: {{tag}}.': + 'Не найдена сохраненная точка восстановления с тегом: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'Точка восстановления с тегом {{tag}} уже существует. Перезаписать?', + 'No chat client available to save conversation.': + 'Нет доступного клиента чата для сохранения диалога.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Точка восстановления диалога сохранена с тегом: {{tag}}.', + 'No conversation found to save.': 'Нет диалога для сохранения.', + 'No chat client available to share conversation.': + 'Нет доступного клиента чата для экспорта диалога.', + 'Invalid file format. Only .md and .json are supported.': + 'Неверный формат файла. Поддерживаются только .md и .json.', + 'Error sharing conversation: {{error}}': + 'Ошибка при экспорте диалога: {{error}}', + 'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}', + 'No conversation found to share.': 'Нет диалога для экспорта.', + 'Share the current conversation to a markdown or json file. Usage: /chat share <путь-к-файлу>': + 'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <путь-к-файлу>', + + // ============================================================================ + // Команды - Резюме + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Сгенерировать сводку проекта и сохранить её в .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'Нет доступного чат-клиента для генерации сводки.', + 'Already generating summary, wait for previous request to complete': + 'Генерация сводки уже выполняется, дождитесь завершения предыдущего запроса', + 'No conversation found to summarize.': + 'Не найдено диалогов для создания сводки.', + 'Failed to generate project context summary: {{error}}': + 'Не удалось сгенерировать сводку контекста проекта: {{error}}', + + // ============================================================================ + // Команды - Модель + // ============================================================================ + 'Switch the model for this session': 'Переключение модели для этой сессии', + 'Content generator configuration not available.': + 'Конфигурация генератора содержимого недоступна.', + 'Authentication type not available.': 'Тип авторизации недоступен.', + 'No models available for the current authentication type ({{authType}}).': + 'Нет доступных моделей для текущего типа авторизации ({{authType}}).', + + // ============================================================================ + // Команды - Очистка + // ============================================================================ + 'Clearing terminal and resetting chat.': 'Очистка терминала и сброс чата.', + 'Clearing terminal.': 'Очистка терминала.', + + // ============================================================================ + // Команды - Сжатие + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Уже выполняется сжатие, дождитесь завершения предыдущего запроса', + 'Failed to compress chat history.': 'Не удалось сжать историю чата.', + 'Failed to compress chat history: {{error}}': + 'Не удалось сжать историю чата: {{error}}', + 'Compressing chat history': 'Сжатие истории чата', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'История чата сжата с {{originalTokens}} до {{newTokens}} токенов.', + 'Compression was not beneficial for this history size.': + 'Сжатие не было полезным для этого размера истории.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Сжатие истории чата не уменьшило размер. Это может указывать на проблемы с промптом сжатия.', + 'Could not compress chat history due to a token counting error.': + 'Не удалось сжать историю чата из-за ошибки подсчета токенов.', + 'Chat history is already compressed.': 'История чата уже сжата.', + + // ============================================================================ + // Команды - Директория + // ============================================================================ + 'Configuration is not available.': 'Конфигурация недоступна.', + 'Please provide at least one path to add.': + 'Пожалуйста, укажите хотя бы один путь для добавления.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.', + "Error adding '{{path}}': {{error}}": + "Ошибка при добавлении '{{path}}': {{error}}", + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': + 'Успешно добавлены файлы GEMINI.md из следующих директорий (если они есть):\n- {{directories}}', + 'Error refreshing memory: {{error}}': + 'Ошибка при обновлении памяти: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Успешно добавлены директории:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Текущие директории рабочего пространства:\n{{directories}}', + + // ============================================================================ + // Команды - Документация + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Пожалуйста, откройте следующий URL в браузере для просмотра документации:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Открытие документации в браузере: {{url}}', + + // ============================================================================ + // Диалоги - Подтверждение инструментов + // ============================================================================ + 'Do you want to proceed?': 'Вы хотите продолжить?', + 'Yes, allow once': 'Да, разрешить один раз', + 'Allow always': 'Всегда разрешать', + No: 'Нет', + 'No (esc)': 'Нет (esc)', + 'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии', + 'Modify in progress:': 'Идет изменение:', + 'Save and close external editor to continue': + 'Сохраните и закройте внешний редактор для продолжения', + 'Apply this change?': 'Применить это изменение?', + 'Yes, allow always': 'Да, всегда разрешать', + 'Modify with external editor': 'Изменить во внешнем редакторе', + 'No, suggest changes (esc)': 'Нет, предложить изменения (esc)', + "Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?", + 'Yes, allow always ...': 'Да, всегда разрешать ...', + 'Yes, and auto-accept edits': 'Да, и автоматически принимать правки', + 'Yes, and manually approve edits': 'Да, и вручную подтверждать правки', + 'No, keep planning (esc)': 'Нет, продолжить планирование (esc)', + 'URLs to fetch:': 'URL для загрузки:', + 'MCP Server: {{server}}': 'MCP-сервер: {{server}}', + 'Tool: {{tool}}': 'Инструмент: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Разрешить выполнение инструмента MCP "{{tool}}" с сервера "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Да, всегда разрешать инструмент "{{tool}}" с сервера "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'Да, всегда разрешать все инструменты с сервера "{{server}}"', + + // ============================================================================ + // Диалоги - Подтверждение оболочки + // ============================================================================ + 'Shell Command Execution': 'Выполнение команды терминала', + 'A custom command wants to run the following shell commands:': + 'Пользовательская команда хочет выполнить следующие команды терминала:', + + // ============================================================================ + // Диалоги - Квота подписки Pro + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Исчерпана квота подписки Pro для {{model}}.', + 'Change auth (executes the /auth command)': + 'Изменить авторизацию (выполняет команду /auth)', + 'Continue with {{model}}': 'Продолжить с {{model}}', + + // ============================================================================ + // Диалоги - Приветствие при возвращении + // ============================================================================ + 'Current Plan:': 'Текущий план:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Прогресс: {{done}}/{{total}} задач выполнено', + ', {{inProgress}} in progress': ', {{inProgress}} в процессе', + 'Pending Tasks:': 'Ожидающие задачи:', + 'What would you like to do?': 'Что вы хотите сделать?', + 'Choose how to proceed with your session:': + 'Выберите, как продолжить сессию:', + 'Start new chat session': 'Начать новую сессию чата', + 'Continue previous conversation': 'Продолжить предыдущий диалог', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 С возвращением! (Последнее обновление: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Общая цель:', + + // ============================================================================ + // Диалоги - Авторизация + // ============================================================================ + 'Get started': 'Начать', + 'How would you like to authenticate for this project?': + 'Как вы хотите авторизоваться для этого проекта?', + 'OpenAI API key is required to use OpenAI authentication.': + 'Для использования авторизации OpenAI требуется ключ API OpenAI.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'Вы должны выбрать метод авторизации для продолжения. Нажмите Ctrl+C снова для выхода.', + '(Use Enter to Set Auth)': '(Enter для установки авторизации)', + 'Terms of Services and Privacy Notice for Qwen Code': + 'Условия обслуживания и уведомление о конфиденциальности для Qwen Code', + 'Qwen OAuth': 'Qwen OAuth', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Не удалось войти. Сообщение: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Авторизация должна быть {{enforcedType}}, но вы сейчас используете {{currentType}}.', + 'Qwen OAuth authentication timed out. Please try again.': + 'Время ожидания авторизации Qwen OAuth истекло. Пожалуйста, попробуйте снова.', + 'Qwen OAuth authentication cancelled.': 'Авторизация Qwen OAuth отменена.', + 'Qwen OAuth Authentication': 'Авторизация Qwen OAuth', + 'Please visit this URL to authorize:': + 'Пожалуйста, посетите этот URL для авторизации:', + 'Or scan the QR code below:': 'Или отсканируйте QR-код ниже:', + 'Waiting for authorization': 'Ожидание авторизации', + 'Time remaining:': 'Осталось времени:', + '(Press ESC or CTRL+C to cancel)': '(Нажмите ESC или CTRL+C для отмены)', + 'Qwen OAuth Authentication Timeout': 'Таймаут авторизации Qwen OAuth', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'Токен OAuth истек (более {{seconds}} секунд). Пожалуйста, выберите метод авторизации снова.', + 'Press any key to return to authentication type selection.': + 'Нажмите любую клавишу для возврата к выбору типа авторизации.', + 'Waiting for Qwen OAuth authentication...': + 'Ожидание авторизации Qwen OAuth...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + 'Примечание: Ваш существующий ключ API в settings.json не будет удален при использовании Qwen OAuth. Вы можете переключиться обратно на авторизацию OpenAI позже при необходимости.', + 'Authentication timed out. Please try again.': + 'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)', + 'Failed to authenticate. Message: {{message}}': + 'Не удалось авторизоваться. Сообщение: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Успешно авторизовано с учетными данными {{authType}}.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Неверное значение QWEN_DEFAULT_AUTH_TYPE: "{{value}}". Допустимые значения: {{validValues}}', + 'OpenAI Configuration Required': 'Требуется конфигурация OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Пожалуйста, введите конфигурацию OpenAI. Вы можете получить ключ API на', + 'API Key:': 'Ключ API:', + 'Invalid credentials: {{errorMessage}}': + 'Неверные учетные данные: {{errorMessage}}', + 'Failed to validate credentials': 'Не удалось проверить учетные данные', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Enter для продолжения, Tab/↑↓ для навигации, Esc для отмены', + + // ============================================================================ + // Диалоги - Модель + // ============================================================================ + 'Select Model': 'Выбрать модель', + '(Press Esc to close)': '(Нажмите Esc для закрытия)', + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': + 'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'Последняя модель Qwen Vision от Alibaba Cloud ModelStudio (версия: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Диалоги - Разрешения + // ============================================================================ + 'Manage folder trust settings': 'Управление настройками доверия к папкам', + + // ============================================================================ + // Строка состояния + // ============================================================================ + 'Using:': 'Используется:', + '{{count}} open file': '{{count}} открытый файл', + '{{count}} open files': '{{count}} открытых файла(ов)', + '(ctrl+g to view)': '(ctrl+g для просмотра)', + '{{count}} {{name}} file': '{{count}} файл {{name}}', + '{{count}} {{name}} files': '{{count}} файла(ов) {{name}}', + '{{count}} MCP server': '{{count}} MCP-сервер', + '{{count}} MCP servers': '{{count}} MCP-сервера(ов)', + '{{count}} Blocked': '{{count}} заблокирован(о)', + '(ctrl+t to view)': '(ctrl+t для просмотра)', + '(ctrl+t to toggle)': '(ctrl+t для переключения)', + 'Press Ctrl+C again to exit.': 'Нажмите Ctrl+C снова для выхода.', + 'Press Ctrl+D again to exit.': 'Нажмите Ctrl+D снова для выхода.', + 'Press Esc again to clear.': 'Нажмите Esc снова для очистки.', + + // ============================================================================ + // Статус MCP + // ============================================================================ + 'No MCP servers configured.': 'Не настроено MCP-серверов.', + 'Please view MCP documentation in your browser:': + 'Пожалуйста, просмотрите документацию MCP в браузере:', + 'or use the cli /docs command': 'или используйте команду cli /docs', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP-серверы запускаются ({{count}} инициализируется)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Примечание: Первый запуск может занять больше времени. Доступность инструментов обновится автоматически.', + 'Configured MCP servers:': 'Настроенные MCP-серверы:', + Ready: 'Готов', + 'Starting... (first startup may take longer)': + 'Запуск... (первый запуск может занять больше времени)', + Disconnected: 'Отключен', + '{{count}} tool': '{{count}} инструмент', + '{{count}} tools': '{{count}} инструмента(ов)', + '{{count}} prompt': '{{count}} промпт', + '{{count}} prompts': '{{count}} промпта(ов)', + '(from {{extensionName}})': '(от {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth истек', + 'OAuth not authenticated': 'OAuth не авторизован', + 'tools and prompts will appear when ready': + 'инструменты и промпты появятся, когда будут готовы', + '{{count}} tools cached': '{{count}} инструмента(ов) в кэше', + 'Tools:': 'Инструменты:', + 'Parameters:': 'Параметры:', + 'Prompts:': 'Промпты:', + Blocked: 'Заблокировано', + '💡 Tips:': '💡 Подсказки:', + Use: 'Используйте', + 'to show server and tool descriptions': + 'для показа описаний сервера и инструментов', + 'to show tool parameter schemas': 'для показа схем параметров инструментов', + 'to hide descriptions': 'для скрытия описаний', + 'to authenticate with OAuth-enabled servers': + 'для авторизации на серверах с поддержкой OAuth', + Press: 'Нажмите', + 'to toggle tool descriptions on/off': + 'для переключения описаний инструментов', + "Starting OAuth authentication for MCP server '{{name}}'...": + "Начало авторизации OAuth для MCP-сервера '{{name}}'...", + 'Restarting MCP servers...': 'Перезапуск MCP-серверов...', + + // ============================================================================ + // Подсказки при запуске + // ============================================================================ + 'Tips for getting started:': 'Подсказки для начала работы:', + '1. Ask questions, edit files, or run commands.': + '1. Задавайте вопросы, редактируйте файлы или выполняйте команды.', + '2. Be specific for the best results.': + '2. Будьте конкретны для лучших результатов.', + 'files to customize your interactions with Qwen Code.': + 'файлы для настройки взаимодействия с Qwen Code.', + 'for more information.': 'для получения дополнительной информации.', + + // ============================================================================ + // Экран выхода / Статистика + // ============================================================================ + 'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!', + 'Interaction Summary': 'Сводка взаимодействия', + 'Session ID:': 'ID сессии:', + 'Tool Calls:': 'Вызовы инструментов:', + 'Success Rate:': 'Процент успеха:', + 'User Agreement:': 'Согласие пользователя:', + reviewed: 'проверено', + 'Code Changes:': 'Изменения кода:', + Performance: 'Производительность', + 'Wall Time:': 'Общее время:', + 'Agent Active:': 'Активность агента:', + 'API Time:': 'Время API:', + 'Tool Time:': 'Время инструментов:', + 'Session Stats': 'Статистика сессии', + 'Model Usage': 'Использование модели', + Reqs: 'Запросов', + 'Input Tokens': 'Входных токенов', + 'Output Tokens': 'Выходных токенов', + 'Savings Highlight:': 'Экономия:', + 'of input tokens were served from the cache, reducing costs.': + 'входных токенов обслужено из кэша, снижая затраты.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Подсказка: Для полной разбивки токенов выполните `/stats model`.', + 'Model Stats For Nerds': 'Статистика модели для гиков', + 'Tool Stats For Nerds': 'Статистика инструментов для гиков', + Metric: 'Метрика', + API: 'API', + Requests: 'Запросы', + Errors: 'Ошибки', + 'Avg Latency': 'Средняя задержка', + Tokens: 'Токены', + Total: 'Всего', + Prompt: 'Промпт', + Cached: 'Кэшировано', + Thoughts: 'Размышления', + Tool: 'Инструмент', + Output: 'Вывод', + 'No API calls have been made in this session.': + 'В этой сессии не было вызовов API.', + 'Tool Name': 'Имя инструмента', + Calls: 'Вызовы', + 'Success Rate': 'Процент успеха', + 'Avg Duration': 'Средняя длительность', + 'User Decision Summary': 'Сводка решений пользователя', + 'Total Reviewed Suggestions:': 'Всего проверено предложений:', + ' » Accepted:': ' » Принято:', + ' » Rejected:': ' » Отклонено:', + ' » Modified:': ' » Изменено:', + ' Overall Agreement Rate:': ' Общий процент согласия:', + 'No tool calls have been made in this session.': + 'В этой сессии не было вызовов инструментов.', + 'Session start time is unavailable, cannot calculate stats.': + 'Время начала сессии недоступно, невозможно рассчитать статистику.', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': + 'Ожидание подтверждения от пользователя...', + '(esc to cancel, {{time}})': '(esc для отмены, {{time}})', + "I'm Feeling Lucky": 'Мне повезёт!', + 'Shipping awesomeness... ': 'Доставляем крутизну... ', + 'Painting the serifs back on...': 'Рисуем засечки на буквах...', + 'Navigating the slime mold...': 'Пробираемся через слизевиков..', + 'Consulting the digital spirits...': 'Советуемся с цифровыми духами...', + 'Reticulating splines...': 'Сглаживание сплайнов...', + 'Warming up the AI hamsters...': 'Разогреваем ИИ-хомячков...', + 'Asking the magic conch shell...': 'Спрашиваем волшебную ракушку...', + 'Generating witty retort...': 'Генерируем остроумный ответ...', + 'Polishing the algorithms...': 'Полируем алгоритмы...', + "Don't rush perfection (or my code)...": + 'Не торопите совершенство (или мой код)...', + 'Brewing fresh bytes...': 'Завариваем свежие байты...', + 'Counting electrons...': 'Пересчитываем электроны...', + 'Engaging cognitive processors...': 'Задействуем когнитивные процессоры...', + 'Checking for syntax errors in the universe...': + 'Ищем синтаксические ошибки во вселенной...', + 'One moment, optimizing humor...': 'Секундочку, оптимизируем юмор...', + 'Shuffling punchlines...': 'Перетасовываем панчлайны...', + 'Untangling neural nets...': 'Распутаваем нейросети...', + 'Compiling brilliance...': 'Компилируем гениальность...', + 'Loading wit.exe...': 'Загружаем yumor.exe...', + 'Summoning the cloud of wisdom...': 'Призываем облако мудрости...', + 'Preparing a witty response...': 'Готовим остроумный ответ...', + "Just a sec, I'm debugging reality...": 'Секунду, идёт отладка реальности...', + 'Confuzzling the options...': 'Запутываем варианты...', + 'Tuning the cosmic frequencies...': 'Настраиваем космические частоты...', + 'Crafting a response worthy of your patience...': + 'Создаем ответ, достойный вашего терпения...', + 'Compiling the 1s and 0s...': 'Компилируем единички и нолики...', + 'Resolving dependencies... and existential crises...': + 'Разрешаем зависимости... и экзистенциальные кризисы...', + 'Defragmenting memories... both RAM and personal...': + 'Дефрагментация памяти... и оперативной, и личной...', + 'Rebooting the humor module...': 'Перезагрузка модуля юмора...', + 'Caching the essentials (mostly cat memes)...': + 'Кэшируем самое важное (в основном мемы с котиками)...', + 'Optimizing for ludicrous speed': 'Оптимизация для безумной скорости', + "Swapping bits... don't tell the bytes...": + 'Меняем биты... только байтам не говорите...', + 'Garbage collecting... be right back...': 'Сборка мусора... скоро вернусь...', + 'Assembling the interwebs...': 'Сборка интернетов...', + 'Converting coffee into code...': 'Превращаем кофе в код...', + 'Updating the syntax for reality...': 'Обновляем синтаксис реальности...', + 'Rewiring the synapses...': 'Переподключаем синапсы...', + 'Looking for a misplaced semicolon...': 'Ищем лишнюю точку с запятой...', + "Greasin' the cogs of the machine...": 'Смазываем шестерёнки машины...', + 'Pre-heating the servers...': 'Разогреваем серверы...', + 'Calibrating the flux capacitor...': 'Калибруем потоковый накопитель...', + 'Engaging the improbability drive...': 'Включаем двигатель невероятности...', + 'Channeling the Force...': 'Направляем Силу...', + 'Aligning the stars for optimal response...': + 'Выравниваем звёзды для оптимального ответа...', + 'So say we all...': 'Так скажем мы все...', + 'Loading the next great idea...': 'Загрузка следующей великой идеи...', + "Just a moment, I'm in the zone...": 'Минутку, я в потоке...', + 'Preparing to dazzle you with brilliance...': + 'Готовлюсь ослепить вас гениальностью...', + "Just a tick, I'm polishing my wit...": 'Секунду, полирую остроумие...', + "Hold tight, I'm crafting a masterpiece...": 'Держитесь, создаю шедевр...', + "Just a jiffy, I'm debugging the universe...": + 'Мигом, отлаживаю вселенную...', + "Just a moment, I'm aligning the pixels...": 'Момент, выравниваю пиксели...', + "Just a sec, I'm optimizing the humor...": 'Секунду, оптимизирую юмор...', + "Just a moment, I'm tuning the algorithms...": + 'Момент, настраиваю алгоритмы...', + 'Warp speed engaged...': 'Варп-скорость включена...', + 'Mining for more Dilithium crystals...': 'Добываем кристаллы дилития...', + "Don't panic...": 'Без паники...', + 'Following the white rabbit...': 'Следуем за белым кроликом...', + 'The truth is in here... somewhere...': 'Истина где-то здесь... внутри...', + 'Blowing on the cartridge...': 'Продуваем картридж...', + 'Loading... Do a barrel roll!': 'Загрузка... Сделай бочку!', + 'Waiting for the respawn...': 'Ждем респауна...', + 'Finishing the Kessel Run in less than 12 parsecs...': + 'Делаем Дугу Кесселя менее чем за 12 парсеков...', + "The cake is not a lie, it's just still loading...": + 'Тортик — не ложь, он просто ещё грузится...', + 'Fiddling with the character creation screen...': + 'Возимся с экраном создания персонажа...', + "Just a moment, I'm finding the right meme...": + 'Минутку, ищу подходящий мем...', + "Pressing 'A' to continue...": "Нажимаем 'A' для продолжения...", + 'Herding digital cats...': 'Пасём цифровых котов...', + 'Polishing the pixels...': 'Полируем пиксели...', + 'Finding a suitable loading screen pun...': + 'Ищем подходящий каламбур для экрана загрузки...', + 'Distracting you with this witty phrase...': + 'Отвлекаем вас этой остроумной фразой...', + 'Almost there... probably...': 'Почти готово... вроде...', + 'Our hamsters are working as fast as they can...': + 'Наши хомячки работают изо всех сил...', + 'Giving Cloudy a pat on the head...': 'Гладим Облачко по голове...', + 'Petting the cat...': 'Гладим кота...', + 'Rickrolling my boss...': 'Рикроллим начальника...', + 'Never gonna give you up, never gonna let you down...': + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...': 'Лабаем бас-гитару...', + 'Tasting the snozberries...': 'Пробуем снузберри на вкус...', + "I'm going the distance, I'm going for speed...": + 'Иду до конца, иду на скорость...', + 'Is this the real life? Is this just fantasy?...': + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...": 'У меня хорошее предчувствие...', + 'Poking the bear...': 'Дразним медведя... (Не лезь...)', + 'Doing research on the latest memes...': 'Изучаем свежие мемы...', + 'Figuring out how to make this more witty...': + 'Думаем, как сделать это остроумнее...', + 'Hmmm... let me think...': 'Хмм... дайте подумать...', + 'What do you call a fish with no eyes? A fsh...': + 'Как называется бумеранг, который не возвращается? Палка...', + 'Why did the computer go to therapy? It had too many bytes...': + 'Почему компьютер простудился? Потому что оставил окна открытыми...', + "Why don't programmers like nature? It has too many bugs...": + 'Почему программисты не любят гулять на улице? Там среда не настроена...', + 'Why do programmers prefer dark mode? Because light attracts bugs...': + 'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...', + 'Why did the developer go broke? Because they used up all their cache...': + 'Почему разработчик разорился? Потому что потратил весь свой кэш...', + "What can you do with a broken pencil? Nothing, it's pointless...": + 'Что можно делать со сломанным карандашом? Ничего — он тупой...', + 'Applying percussive maintenance...': 'Провожу настройку методом тыка...', + 'Searching for the correct USB orientation...': + 'Ищем, какой стороной вставлять флешку...', + 'Ensuring the magic smoke stays inside the wires...': + 'Следим, чтобы волшебный дым не вышел из проводов...', + 'Rewriting in Rust for no particular reason...': + 'Переписываем всё на Rust без особой причины...', + 'Trying to exit Vim...': 'Пытаемся выйти из Vim...', + 'Spinning up the hamster wheel...': 'Раскручиваем колесо для хомяка...', + "That's not a bug, it's an undocumented feature...": 'Это не баг, а фича...', + 'Engage.': 'Поехали!', + "I'll be back... with an answer.": 'Я вернусь... с ответом.', + 'My other process is a TARDIS...': 'Мой другой процесс — это ТАРДИС...', + 'Communing with the machine spirit...': 'Общаемся с духом машины...', + 'Letting the thoughts marinate...': 'Даем мыслям замариноваться...', + 'Just remembered where I put my keys...': + 'Только что вспомнил, куда положил ключи...', + 'Pondering the orb...': 'Размышляю над сферой...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.": + 'Я видел такое, во что вы, люди, просто не поверите... например, пользователя, читающего сообщения загрузки.', + 'Initiating thoughtful gaze...': 'Инициируем задумчивый взгляд...', + "What's a computer's favorite snack? Microchips.": + 'Что сервер заказывает в баре? Пинг-коладу.', + "Why do Java developers wear glasses? Because they don't C#.": + 'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...', + 'Charging the laser... pew pew!': 'Заряжаем лазер... пиу-пиу!', + 'Dividing by zero... just kidding!': 'Делим на ноль... шучу!', + 'Looking for an adult superviso... I mean, processing.': + 'Ищу взрослых для присмот... в смысле, обрабатываю.', + 'Making it go beep boop.': 'Делаем бип-буп.', + 'Buffering... because even AIs need a moment.': + 'Буферизация... даже ИИ нужно мгновение.', + 'Entangling quantum particles for a faster response...': + 'Запутываем квантовые частицы для быстрого ответа...', + 'Polishing the chrome... on the algorithms.': + 'Полируем хром... на алгоритмах.', + 'Are you not entertained? (Working on it!)': + 'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!', + 'Summoning the code gremlins... to help, of course.': + 'Призываем гремлинов кода... для помощи, конечно же.', + 'Just waiting for the dial-up tone to finish...': + 'Ждем, пока закончится звук dial-up модема...', + 'Recalibrating the humor-o-meter.': 'Перекалибровка юморометра.', + 'My other loading screen is even funnier.': + 'Мой другой экран загрузки ещё смешнее.', + "Pretty sure there's a cat walking on the keyboard somewhere...": + 'Кажется, где-то по клавиатуре гуляет кот...', + 'Enhancing... Enhancing... Still loading.': + 'Улучшаем... Ещё улучшаем... Всё ещё грузится.', + "It's not a bug, it's a feature... of this loading screen.": + 'Это не баг, это фича... экрана загрузки.', + 'Have you tried turning it off and on again? (The loading screen, not me.)': + 'Пробовали выключить и включить снова? (Экран загрузки, не меня!)', + 'Constructing additional pylons...': 'Нужно построить больше пилонов...', +}; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index adeb85f1..dc00d068 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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:': '工具调用:', diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 9d649b2f..7c8e6fc5 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -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', () => ({ diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 100fbef9..d3877a8a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 59f26cf2..fd825b9d 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -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: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index edda3d4d..ff16c53d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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(''); 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, diff --git a/packages/cli/src/ui/commands/corgiCommand.test.ts b/packages/cli/src/ui/commands/corgiCommand.test.ts deleted file mode 100644 index 3c25e8cd..00000000 --- a/packages/cli/src/ui/commands/corgiCommand.test.ts +++ /dev/null @@ -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.'); - }); -}); diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts deleted file mode 100644 index 2da6ad3e..00000000 --- a/packages/cli/src/ui/commands/corgiCommand.ts +++ /dev/null @@ -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(); - }, -}; diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts new file mode 100644 index 00000000..001ccd8e --- /dev/null +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -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(); + 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(); + 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) => { + 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'), + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index ba04920b..455465ab 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -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 = { + const langDisplayNames: Partial> = { zh: '中文(zh-CN)', en: 'English(en-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 - ${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 - ' + 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 => { + if (args.trim().length > 0) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, 'ru'); + }, + }, ], }, { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a2a352cb..f2ec2173 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 084cd746..d660d704 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial = {}): 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', diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 776817a6..71f278df 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -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 && } - {corgiMode && ( - - | - - - - `) - - - )} {!showErrorDetails && errorCount > 0 && ( | diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 766e851a..19aa3af8 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -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('', () => { 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'); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index c8d79e0e..c38edc75 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -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 = ({ duration, -}) => ( - -); +}) => { + const { stats } = useSessionStats(); + + // Only show the resume message if there were messages in the session + const hasMessages = stats.promptCount > 0; + + return ( + <> + + {hasMessages && ( + + + {t('To continue this session, run')}{' '} + + qwen --resume {stats.sessionId} + + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index f96ec33c..9e4d294e 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -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, }, diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index 7c925f72..dfa39ba8 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -6,7 +6,7 @@ exports[` > 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[` > 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" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 7c2c04f9..fbc2244b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -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* │ -│ │ │ ▼ │ │ │ │ │ diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ac2f5f10..62e54204 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -54,7 +54,6 @@ export interface UIState { qwenAuthState: QwenAuthState; editorError: string | null; isEditorDialogOpen: boolean; - corgiMode: boolean; debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 55fec0c3..42ce4099 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -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 diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 553accb7..6439c934 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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, diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts index 1475aa52..e8beb86f 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts @@ -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 }, + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts index e632c827..7c5cd043 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -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]); }; diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index fc75924a..77929333 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { loadHistory: (_newHistory) => {}, pendingItem: null, setPendingItem: (_item) => {}, - toggleCorgiMode: () => {}, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, diff --git a/packages/cli/src/utils/attentionNotification.ts b/packages/cli/src/utils/attentionNotification.ts index 26dc2a25..e166444f 100644 --- a/packages/cli/src/utils/attentionNotification.ts +++ b/packages/cli/src/utils/attentionNotification.ts @@ -13,6 +13,7 @@ export enum AttentionNotificationReason { export interface TerminalNotificationOptions { stream?: Pick; + 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; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b507c9c5..073f2aa1 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 42def511..c8b7b1e7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 04ff2153..2dfd5c5f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 */ diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index e29b4640..54420fbb 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -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; + + 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; + + 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; + const nested = properties?.['nested'] as Record; + const nestedProperties = nested?.['properties'] as Record; + + 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, + ), + ).toBeNull(); + expect( + converter.convertGeminiToolParametersToOpenAI( + undefined as unknown as Record, + ), + ).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); + }); + }); }); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index b22eb963..2de99d80 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -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), - }; - parameters = paramsCopy; - } + // Create a shallow copy to avoid mutating the original object + const paramsCopy = { + ...(func.parametersJsonSchema as Record), + }; + parameters = paramsCopy; } else if (func.parameters) { // Gemini tool format - convert parameters to OpenAI format parameters = this.convertGeminiToolParametersToOpenAI( diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 2df72221..4a5b7748 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -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 } : {}), }, }; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/types.ts b/packages/core/src/core/openaiContentGenerator/provider/types.ts index ea7c434d..362ec69a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/types.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/types.ts @@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = { metadata: { sessionId?: string; promptId: string; + channel?: string; }; }; diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 23c26296..0c401f90 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -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', () => { diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index c4cfa933..77c5345a 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -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 { - 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 { } // 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. + } } } diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 0a2b8b74..e63511df 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -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; diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index b0f35709..f6ed8198 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -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", diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index c54d9104..43ff09da 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -139,6 +139,7 @@ export class ProcessTransport implements Transport { 'stream-json', '--output-format', 'stream-json', + '--channel=SDK', ]; if (this.options.model) { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 7365c059..64e89c6d 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -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", diff --git a/packages/vscode-ide-companion/.vscodeignore b/packages/vscode-ide-companion/.vscodeignore index e74d0536..18e07a04 100644 --- a/packages/vscode-ide-companion/.vscodeignore +++ b/packages/vscode-ide-companion/.vscodeignore @@ -1,5 +1,6 @@ ** !dist/ +!dist/** ../ ../../ !LICENSE diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index c278976f..8698275b 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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", diff --git a/packages/vscode-ide-companion/scripts/copy-bundled-cli.js b/packages/vscode-ide-companion/scripts/copy-bundled-cli.js new file mode 100644 index 00000000..d720e47f --- /dev/null +++ b/packages/vscode-ide-companion/scripts/copy-bundled-cli.js @@ -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); +}); diff --git a/packages/vscode-ide-companion/scripts/prepackage.js b/packages/vscode-ide-companion/scripts/prepackage.js new file mode 100644 index 00000000..8db18a69 --- /dev/null +++ b/packages/vscode-ide-companion/scripts/prepackage.js @@ -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(); diff --git a/packages/vscode-ide-companion/src/cli/cliContextManager.ts b/packages/vscode-ide-companion/src/cli/cliContextManager.ts deleted file mode 100644 index c812a08e..00000000 --- a/packages/vscode-ide-companion/src/cli/cliContextManager.ts +++ /dev/null @@ -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; - } -} diff --git a/packages/vscode-ide-companion/src/cli/cliDetector.ts b/packages/vscode-ide-companion/src/cli/cliDetector.ts deleted file mode 100644 index 875c2858..00000000 --- a/packages/vscode-ide-companion/src/cli/cliDetector.ts +++ /dev/null @@ -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 { - 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', - }; - } -} diff --git a/packages/vscode-ide-companion/src/cli/cliInstaller.ts b/packages/vscode-ide-companion/src/cli/cliInstaller.ts deleted file mode 100644 index 4eb0d0e7..00000000 --- a/packages/vscode-ide-companion/src/cli/cliInstaller.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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); - } - } -} diff --git a/packages/vscode-ide-companion/src/cli/cliPathDetector.ts b/packages/vscode-ide-companion/src/cli/cliPathDetector.ts deleted file mode 100644 index 7f329873..00000000 --- a/packages/vscode-ide-companion/src/cli/cliPathDetector.ts +++ /dev/null @@ -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.`, - }; -} diff --git a/packages/vscode-ide-companion/src/cli/cliVersionManager.ts b/packages/vscode-ide-companion/src/cli/cliVersionManager.ts deleted file mode 100644 index 72ef3d2e..00000000 --- a/packages/vscode-ide-companion/src/cli/cliVersionManager.ts +++ /dev/null @@ -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 { - 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 { - 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 { - const versionInfo = await this.detectCliVersion(forceRefresh); - return versionInfo.features.supportsSessionLoad; - } -} diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts index 18a69641..9f06e4fa 100644 --- a/packages/vscode-ide-companion/src/constants/acpSchema.ts +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -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; diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 2adfaef1..c27a7e9d 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -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, diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 8324f802..69fabbc4 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -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' }); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 5486e14d..4b2c4028 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -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 { @@ -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, }; diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts index db7802ce..8766fdf3 100644 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -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, diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts index 8812282a..cfa299bf 100644 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -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; } diff --git a/packages/vscode-ide-companion/src/services/authStateManager.ts b/packages/vscode-ide-companion/src/services/authStateManager.ts deleted file mode 100644 index 566a4afb..00000000 --- a/packages/vscode-ide-companion/src/services/authStateManager.ts +++ /dev/null @@ -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 { - 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 { - 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 { - // 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 { - // 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 { - // 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( - AuthStateManager.AUTH_STATE_KEY, - ); - console.log('[AuthStateManager] Auth state:', a); - return a; - } - - /** - * Get auth state info for debugging - */ - async getAuthInfo(): Promise { - 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}`; - } -} diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index a57d15b7..e60ee3a2 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -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 | 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 { + cliEntryPath: string, + options?: AgentConnectOptions, + ): Promise { 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> = []; - // 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>) + : []; + } - // 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> = []; - - // 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>) - : []; - } + 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> = []; - 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> = []; - - if (Array.isArray(res)) { - items = res; - } else if (typeof res === 'object' && res !== null && 'items' in res) { - const responseObject = res as { - items?: Array>; - }; - 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>; + }; + 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 { 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, 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 { - // 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 { - return this.loadSession(sessionId); - } - /** * Create new session * @@ -1181,95 +1075,70 @@ export class QwenAgentManager { */ async createNewSession( workingDir: string, - authStateManager?: AuthStateManager, + options?: AgentSessionOptions, ): Promise { + 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); } diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 11e7199a..c66ee23c 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -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 { + cliEntryPath: string, + options?: { + autoAuthenticate?: boolean; + }, + ): Promise { 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('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 { 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 } } diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 2bd609bb..9336a060 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -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 { - 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 diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 6e2d065d..3fc4e484 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -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 { // 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 { - 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 { + 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(); + 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; + try { + obj = JSON.parse(trimmed) as Record; + } 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; + 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 */ diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index e27fbe67..d7b24bb2 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -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'; /** diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 1fb4de17..5ddbfd06 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -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 diff --git a/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts new file mode 100644 index 00000000..fe1f37e1 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts @@ -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'; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 90ebbb87..4cffd4eb 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -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; - onEndTurn?: () => void; + onEndTurn?: (reason?: string) => void; onModeInfo?: (info: { currentModeId?: ApprovalModeValue; availableModes?: Array<{ diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts index b49bd027..7ada3aed 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -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 { 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 { diff --git a/packages/vscode-ide-companion/src/utils/authErrors.ts b/packages/vscode-ide-companion/src/utils/authErrors.ts new file mode 100644 index 00000000..8b0e6af9 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authErrors.ts @@ -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)); +}; diff --git a/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts new file mode 100644 index 00000000..362867c2 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts @@ -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 | 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; + }); +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 4b51d6b6..5eacdabf 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -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([]); + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading const messagesEndRef = useRef( null, ) as React.RefObject; @@ -90,9 +93,13 @@ export const App: React.FC = () => { const getCompletionItems = React.useCallback( async (trigger: '@' | '/', query: string): Promise => { 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 = ; 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 ( -
+
+ {/* Top-level loading overlay */} + {isLoading && ( +
+
+
+

+ Preparing Qwen Code... +

+
+
+ )} + {
- {!hasContent ? ( - + {!hasContent && !isLoading ? ( + isAuthenticated === false ? ( + { + vscode.postMessage({ type: 'login', data: {} }); + messageHandling.setWaitingForResponse( + 'Logging in to Qwen Code...', + ); + }} + /> + ) : isAuthenticated === null ? ( + + ) : ( + + ) ) : ( <> {/* 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). */} -
- {messageHandling.isWaitingForResponse && - messageHandling.loadingMessage && ( + + {/* Waiting message positioned fixed above the input form to avoid layout shifts */} + {messageHandling.isWaitingForResponse && + messageHandling.loadingMessage && ( +
- )} -
- +
+ )}
)}
- 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 && ( + 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 && ( { // 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 { 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 { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + async initializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + 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 { + 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 { 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 { + private async loadCurrentSessionMessages(options?: { + autoAuthenticate?: boolean; + }): Promise { + 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 { - 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 { 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}`); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx index 167a376d..f667b849 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx @@ -92,9 +92,8 @@ export const CompletionMenu: React.FC = ({ 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]', diff --git a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx index 081352b8..1b424e24 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx @@ -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 = ({ + 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 (
{/* Qwen Logo */}
- Qwen Logo + {iconUri ? ( + Qwen Logo { + // 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); + } + }} + /> + ) : ( +
+ Q +
+ )}
- What to do first? Ask about this codebase or we can start writing - code. + {description}
diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index fe86ea99..86ba42be 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -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 = ({ isWaitingForResponse, isComposing, editMode, - thinkingEnabled, + // thinkingEnabled, // Temporarily disabled activeFileName, activeSelection, skipAutoActiveContext, @@ -103,7 +103,7 @@ export const InputForm: React.FC = ({ onSubmit, onCancel, onToggleEditMode, - onToggleThinking, + // onToggleThinking, // Temporarily disabled onToggleSkipAutoActiveContext, onShowCommandMenu, onAttachContext, @@ -113,6 +113,7 @@ export const InputForm: React.FC = ({ 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 = ({ return (
@@ -179,10 +180,16 @@ export const InputForm: React.FC = ({ data-placeholder="Ask Qwen Code …" // Use a data flag so CSS can show placeholder even if the browser // inserts an invisible
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 = ({ {/* Spacer */}
+ {/* @yiliang114. closed temporarily */} {/* Thinking button */} - + */} {/* Command button */} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx new file mode 100644 index 00000000..2eddc4d3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -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 = ({ onLogin }) => { + const iconUri = generateIconUrl('icon.png'); + + return ( +
+
+
+ {/* Application icon container */} +
+ Qwen Code Logo +
+ +
+

+ Welcome to Qwen Code +

+

+ Unlock the power of AI to understand, navigate, and transform your + codebase faster than ever before. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx index ab7f6d51..1b744c1d 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx @@ -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 { diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx index ed8badcc..84712efa 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx @@ -75,7 +75,11 @@ export const AssistantMessage: React.FC = ({ whiteSpace: 'normal', }} > - +
diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts index 4ae9efd6..ceb2cb2b 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts @@ -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 = { - 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 diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index f82525f7..28ecbbd3 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -49,7 +49,6 @@ export class FileMessageHandler extends BaseMessageHandler { break; case 'openDiff': - console.log('[FileMessageHandler ===== ] openDiff called with:', data); await this.handleOpenDiff(data); break; diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index adf94e29..353dbaaf 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -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(); - } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 741d9684..51dfbdd9 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -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) | 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 { + 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 | 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 { + 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}` }, + }); + } + } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts deleted file mode 100644 index 7ea8e732..00000000 --- a/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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}` }, - }); - } - } -} diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts index eca8437d..8bccc658 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -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(undefined); + // Search debounce timer const searchTimerRef = useRef(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], diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 8f6848c1..b18843ef 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -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(() => { diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts index 9f67bcc8..a91594c0 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -14,6 +14,7 @@ interface UseMessageSubmitProps { setInputText: (text: string) => void; inputFieldRef: React.RefObject; 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, ], ); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index cd312361..c8d507f2 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -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 diff --git a/packages/vscode-ide-companion/src/webview/styles/styles.css b/packages/vscode-ide-companion/src/webview/styles/styles.css index 4c3db053..956912cb 100644 --- a/packages/vscode-ide-companion/src/webview/styles/styles.css +++ b/packages/vscode-ide-companion/src/webview/styles/styles.css @@ -5,7 +5,6 @@ */ /* Import component styles */ -@import '../components/messages/Assistant/AssistantMessage.css'; @import './timeline.css'; @import '../components/messages/MarkdownRenderer/MarkdownRenderer.css'; diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css index 4c4b5e08..46d803d5 100644 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -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
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 { diff --git a/packages/vscode-ide-companion/src/webview/styles/timeline.css b/packages/vscode-ide-companion/src/webview/styles/timeline.css index 25d5cc85..033e82d2 100644 --- a/packages/vscode-ide-companion/src/webview/styles/timeline.css +++ b/packages/vscode-ide-companion/src/webview/styles/timeline.css @@ -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; -} \ No newline at end of file +} diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts index 31326cc6..e11f4bce 100644 --- a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts +++ b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts @@ -62,3 +62,38 @@ export const groupSessionsByDate = ( .filter(([, sessions]) => sessions.length > 0) .map(([label, sessions]) => ({ label, sessions })); }; + +/** + * Time ago formatter + * + * @param timestamp - ISO timestamp string + * @returns Formatted time string + */ +export const getTimeAgo = (timestamp: string): string => { + if (!timestamp) { + return ''; + } + const now = new Date().getTime(); + const then = new Date(timestamp).getTime(); + const diffMs = now - then; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'now'; + } + if (diffMins < 60) { + return `${diffMins}m`; + } + if (diffHours < 24) { + return `${diffHours}h`; + } + if (diffDays === 1) { + return 'Yesterday'; + } + if (diffDays < 7) { + return `${diffDays}d`; + } + return new Date(timestamp).toLocaleDateString(); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts b/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts deleted file mode 100644 index 0231f383..00000000 --- a/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Minimal line-diff utility for webview previews. - * - * This is a lightweight LCS-based algorithm to compute add/remove operations - * between two texts. It intentionally avoids heavy dependencies and is - * sufficient for rendering a compact preview inside the chat. - */ - -export type DiffOp = - | { type: 'add'; line: string; newIndex: number } - | { type: 'remove'; line: string; oldIndex: number }; - -/** - * Compute a minimal line-diff (added/removed only). - * - Equal lines are omitted from output by design (we only preview changes). - * - Order of operations follows the new text progression so the preview feels natural. - */ -export function computeLineDiff( - oldText: string | null | undefined, - newText: string | undefined, -): DiffOp[] { - const a = (oldText || '').split('\n'); - const b = (newText || '').split('\n'); - - const n = a.length; - const m = b.length; - - // Build LCS DP table - const dp: number[][] = Array.from({ length: n + 1 }, () => - new Array(m + 1).fill(0), - ); - for (let i = n - 1; i >= 0; i--) { - for (let j = m - 1; j >= 0; j--) { - if (a[i] === b[j]) { - dp[i][j] = dp[i + 1][j + 1] + 1; - } else { - dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - } - - // Walk to produce operations - const ops: DiffOp[] = []; - let i = 0; - let j = 0; - while (i < n && j < m) { - if (a[i] === b[j]) { - i++; - j++; - } else if (dp[i + 1][j] >= dp[i][j + 1]) { - // remove a[i] - ops.push({ type: 'remove', line: a[i], oldIndex: i }); - i++; - } else { - // add b[j] - ops.push({ type: 'add', line: b[j], newIndex: j }); - j++; - } - } - - // Remaining tails - while (i < n) { - ops.push({ type: 'remove', line: a[i], oldIndex: i }); - i++; - } - while (j < m) { - ops.push({ type: 'add', line: b[j], newIndex: j }); - j++; - } - - return ops; -} - -/** - * Truncate a long list of operations for preview purposes. - * Keeps first `head` and last `tail` operations, inserting a gap marker. - */ -export function truncateOps( - ops: T[], - head = 120, - tail = 80, -): { items: T[]; truncated: boolean; omitted: number } { - if (ops.length <= head + tail) { - return { items: ops, truncated: false, omitted: 0 }; - } - const items = [...ops.slice(0, head), ...ops.slice(-tail)]; - return { items, truncated: true, omitted: ops.length - head - tail }; -} diff --git a/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts b/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts deleted file mode 100644 index b1610597..00000000 --- a/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Time ago formatter - * - * @param timestamp - ISO timestamp string - * @returns Formatted time string - */ -export const getTimeAgo = (timestamp: string): string => { - if (!timestamp) { - return ''; - } - const now = new Date().getTime(); - const then = new Date(timestamp).getTime(); - const diffMs = now - then; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) { - return 'now'; - } - if (diffMins < 60) { - return `${diffMins}m`; - } - if (diffHours < 24) { - return `${diffHours}h`; - } - if (diffDays === 1) { - return 'Yesterday'; - } - if (diffDays < 7) { - return `${diffDays}d`; - } - return new Date(timestamp).toLocaleDateString(); -};