diff --git a/README.md b/README.md index c6230b96..b277d4dd 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ npm install -g . brew install qwen-code ``` +## VS Code Extension + +In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more. + +> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md). + ## Quick Start ```bash diff --git a/eslint.config.js b/eslint.config.js index 5b3b7f3d..5e5e1b85 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -75,6 +75,8 @@ export default tseslint.config( }, }, rules: { + // We use TypeScript for React components; prop-types are unnecessary + 'react/prop-types': 'off', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], @@ -111,10 +113,14 @@ export default tseslint.config( { allow: [ 'react-dom/test-utils', + 'react-dom/client', 'memfs/lib/volume.js', 'yargs/**', 'msw/node', - '**/generated/**' + '**/generated/**', + './styles/tailwind.css', + './styles/App.css', + './styles/style.css' ], }, ], diff --git a/package-lock.json b/package-lock.json index 4fba584c..ff815db9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2666,6 +2679,354 @@ "node": ">=14" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3658,6 +4019,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/marked": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", @@ -3665,6 +4044,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3718,6 +4104,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qrcode-terminal": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -4921,6 +5314,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -5065,11 +5479,17 @@ "streamx": "^2.15.0" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5350,6 +5770,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5425,6 +5883,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bignumber.js": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", @@ -5434,6 +5902,19 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -5601,6 +6082,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5746,6 +6261,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -5884,6 +6430,24 @@ "entities": "^6.0.0" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -6486,6 +7050,19 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -6803,6 +7380,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -6845,6 +7429,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -7027,6 +7618,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -8330,6 +8928,20 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -9194,6 +9806,15 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9653,6 +10274,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -10264,6 +10898,16 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -10666,11 +11310,17 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dev": true, "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -11103,7 +11753,6 @@ "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -11142,7 +11791,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, "license": "MIT" }, "node_modules/media-typer": { @@ -11448,6 +12096,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nan": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", @@ -11593,6 +12253,13 @@ "nan": "^2.17.0" } }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/node-sarif-builder": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz", @@ -11631,6 +12298,16 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", @@ -11970,6 +12647,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12566,6 +13253,16 @@ "node": ">=4" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -12636,6 +13333,140 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -12832,7 +13663,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13077,6 +13907,26 @@ "node": ">=0.8" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-package-json-fast": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", @@ -13207,6 +14057,22 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13644,6 +14510,29 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.94.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", + "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -14503,6 +15392,39 @@ "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -14669,6 +15591,95 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/tar": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", @@ -14816,6 +15827,29 @@ "url": "https://bevry.me/fund" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/thingies": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", @@ -15052,6 +16086,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -15306,7 +16347,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, "license": "MIT" }, "node_modules/ufo": { @@ -15388,6 +16428,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/update-notifier": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", @@ -19124,19 +20195,31 @@ "@modelcontextprotocol/sdk": "^1.15.1", "cors": "^2.8.5", "express": "^5.1.0", + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "semver": "^7.7.2", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/markdown-it": "^14.1.2", "@types/node": "20.x", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/semver": "^7.7.1", "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.6.0", + "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", + "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, @@ -19144,6 +20227,34 @@ "vscode": "^1.99.0" } }, + "packages/vscode-ide-companion/node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "packages/vscode-ide-companion/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "packages/vscode-ide-companion/node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "packages/vscode-ide-companion/node_modules/@types/vscode": { "version": "1.99.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", @@ -19163,6 +20274,13 @@ "node": ">= 0.6" } }, + "packages/vscode-ide-companion/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "packages/vscode-ide-companion/node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -19237,6 +20355,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "packages/vscode-ide-companion/node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "packages/vscode-ide-companion/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "packages/vscode-ide-companion/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "packages/vscode-ide-companion/node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 8866f163..afbf750d 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -1,5 +1,26 @@ This file contains third-party software notices and license terms. +============================================================ +semver@7.7.2 +(git+https://github.com/npm/node-semver.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ============================================================ @modelcontextprotocol/sdk@1.15.1 (git+https://github.com/modelcontextprotocol/typescript-sdk.git) @@ -2317,3 +2338,520 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +============================================================ +markdown-it@14.1.0 +(No repository found) + +Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +argparse@2.0.1 +(No repository found) + +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the Internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the Internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +entities@4.5.0 +(git://github.com/fb55/entities.git) + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +============================================================ +linkify-it@5.0.0 +(No repository found) + +Copyright (c) 2015 Vitaly Puzrin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +uc.micro@2.1.0 +(No repository found) + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +mdurl@2.0.0 +(No repository found) + +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +.parse() is based on Joyent's node.js `url` code: + +Copyright Joyent, Inc. and other Node contributors. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. + + +============================================================ +punycode.js@2.3.1 +(https://github.com/mathiasbynens/punycode.js.git) + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +react@19.1.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +react-dom@19.1.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +scheduler@0.26.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/packages/vscode-ide-companion/README.md b/packages/vscode-ide-companion/README.md index c6788769..a5e4980d 100644 --- a/packages/vscode-ide-companion/README.md +++ b/packages/vscode-ide-companion/README.md @@ -19,6 +19,63 @@ To use this extension, you'll need: - VS Code version 1.101.0 or newer - Qwen Code (installed separately) running within the VS Code integrated terminal +# Development and Debugging + +To debug and develop this extension locally: + +1. **Clone the repository** + + ```bash + git clone https://github.com/QwenLM/qwen-code.git + cd qwen-code + ``` + +2. **Install dependencies** + + ```bash + npm install + # or if using pnpm + pnpm install + ``` + +3. **Start debugging** + + ```bash + code . # Open the project root in VS Code + ``` + - Open the `packages/vscode-ide-companion/src/extension.ts` file + - Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`) + - Select **"Launch Companion VS Code Extension"** from the debug dropdown + - Press `F5` to launch Extension Development Host + +4. **Make changes and reload** + - Edit the source code in the original VS Code window + - To see your changes, reload the Extension Development Host window by: + - Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS) + - Or clicking the "Reload" button in the debug toolbar + +5. **View logs and debug output** + - Open the Debug Console in the original VS Code window to see extension logs + - In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs + +## Build for Production + +To build the extension for distribution: + +```bash +npm run compile +# or +pnpm run compile +``` + +To package the extension as a VSIX file: + +```bash +npx vsce package +# or +pnpm vsce package +``` + # Terms of Service and Privacy Notice By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md). diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 7de7c7ad..032c3c13 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = { }, }; +/** + * @type {import('esbuild').Plugin} + */ +const cssInjectPlugin = { + name: 'css-inject', + setup(build) { + // Handle CSS files + build.onLoad({ filter: /\.css$/ }, async (args) => { + const fs = await import('fs'); + const postcss = (await import('postcss')).default; + const tailwindcss = (await import('tailwindcss')).default; + const autoprefixer = (await import('autoprefixer')).default; + + let css = await fs.promises.readFile(args.path, 'utf8'); + + // For styles.css, we need to resolve @import statements + if (args.path.endsWith('styles.css')) { + // Read all imported CSS files and inline them + const importRegex = /@import\s+'([^']+)';/g; + let match; + const basePath = args.path.substring(0, args.path.lastIndexOf('/')); + while ((match = importRegex.exec(css)) !== null) { + const importPath = match[1]; + // Resolve relative paths correctly + let fullPath; + if (importPath.startsWith('./')) { + fullPath = basePath + importPath.substring(1); + } else if (importPath.startsWith('../')) { + fullPath = basePath + '/' + importPath; + } else { + fullPath = basePath + '/' + importPath; + } + + try { + const importedCss = await fs.promises.readFile(fullPath, 'utf8'); + css = css.replace(match[0], importedCss); + } catch (err) { + console.warn(`Could not import ${fullPath}: ${err.message}`); + } + } + } + + // Process with PostCSS (Tailwind + Autoprefixer) + const result = await postcss([tailwindcss, autoprefixer]).process(css, { + from: args.path, + to: args.path, + }); + + return { + contents: ` + const style = document.createElement('style'); + style.textContent = ${JSON.stringify(result.css)}; + document.head.appendChild(style); + `, + loader: 'js', + }; + }); + }, +}; + async function main() { - const ctx = await esbuild.context({ + // Build extension + const extensionCtx = await esbuild.context({ entryPoints: ['src/extension.ts'], bundle: true, format: 'cjs', @@ -55,11 +116,30 @@ async function main() { ], loader: { '.node': 'file' }, }); + + // Build webview + const webviewCtx = await esbuild.context({ + entryPoints: ['src/webview/index.tsx'], + bundle: true, + format: 'iife', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'browser', + outfile: 'dist/webview.js', + logLevel: 'silent', + plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin], + jsx: 'automatic', // Use new JSX transform (React 17+) + define: { + 'process.env.NODE_ENV': production ? '"production"' : '"development"', + }, + }); + if (watch) { - await ctx.watch(); + await Promise.all([extensionCtx.watch(), webviewCtx.watch()]); } else { - await ctx.rebuild(); - await ctx.dispose(); + await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]); + await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]); } } diff --git a/packages/vscode-ide-companion/eslint.config.mjs b/packages/vscode-ide-companion/eslint.config.mjs index 02fc9fba..4b444a9b 100644 --- a/packages/vscode-ide-companion/eslint.config.mjs +++ b/packages/vscode-ide-companion/eslint.config.mjs @@ -6,20 +6,44 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; +import reactHooks from 'eslint-plugin-react-hooks'; +import importPlugin from 'eslint-plugin-import'; export default [ { - files: ['**/*.ts'], + files: ['**/*.ts', '**/*.tsx'], + }, + { + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + module: 'readonly', + require: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + process: 'readonly', + console: 'readonly', + }, + }, }, { plugins: { '@typescript-eslint': typescriptEslint, + 'react-hooks': reactHooks, + import: importPlugin, }, languageOptions: { parser: tsParser, ecmaVersion: 2022, sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, }, rules: { @@ -30,6 +54,17 @@ export default [ format: ['camelCase', 'PascalCase'], }, ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + // Restrict deep imports but allow known-safe exceptions used by the webview + // - react-dom/client: required for React 18's createRoot API + // - ./styles/**: local CSS modules loaded by the webview + 'import/no-internal-modules': [ + 'error', + { + allow: ['react-dom/client', './styles/**'], + }, + ], curly: 'warn', eqeqeq: 'warn', diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 4d1f1023..c278976f 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -54,6 +54,15 @@ { "command": "qwen-code.showNotices", "title": "Qwen Code: View Third-Party Notices" + }, + { + "command": "qwen-code.openChat", + "title": "Qwen Code: Open", + "icon": "./assets/icon.png" + }, + { + "command": "qwen-code.login", + "title": "Qwen Code: Login" } ], "menus": { @@ -65,6 +74,10 @@ { "command": "qwen.diff.cancel", "when": "qwen.diff.isVisible" + }, + { + "command": "qwen-code.login", + "when": "false" } ], "editor/title": [ @@ -77,6 +90,10 @@ "command": "qwen.diff.cancel", "when": "qwen.diff.isVisible", "group": "navigation" + }, + { + "command": "qwen-code.openChat", + "group": "navigation" } ] }, @@ -115,21 +132,33 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/markdown-it": "^14.1.2", "@types/node": "20.x", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/semver": "^7.7.1", "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.6.0", + "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", + "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "dependencies": { + "semver": "^7.7.2", "@modelcontextprotocol/sdk": "^1.15.1", "cors": "^2.8.5", "express": "^5.1.0", + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "zod": "^3.25.76" } } diff --git a/packages/vscode-ide-companion/postcss.config.js b/packages/vscode-ide-companion/postcss.config.js new file mode 100644 index 00000000..49f4aea7 --- /dev/null +++ b/packages/vscode-ide-companion/postcss.config.js @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-undef */ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/vscode-ide-companion/src/cli/cliContextManager.ts b/packages/vscode-ide-companion/src/cli/cliContextManager.ts new file mode 100644 index 00000000..c812a08e --- /dev/null +++ b/packages/vscode-ide-companion/src/cli/cliContextManager.ts @@ -0,0 +1,58 @@ +/** + * @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 new file mode 100644 index 00000000..875c2858 --- /dev/null +++ b/packages/vscode-ide-companion/src/cli/cliDetector.ts @@ -0,0 +1,215 @@ +/** + * @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 new file mode 100644 index 00000000..4eb0d0e7 --- /dev/null +++ b/packages/vscode-ide-companion/src/cli/cliInstaller.ts @@ -0,0 +1,225 @@ +/** + * @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 new file mode 100644 index 00000000..7f329873 --- /dev/null +++ b/packages/vscode-ide-companion/src/cli/cliPathDetector.ts @@ -0,0 +1,128 @@ +/** + * @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 new file mode 100644 index 00000000..72ef3d2e --- /dev/null +++ b/packages/vscode-ide-companion/src/cli/cliVersionManager.ts @@ -0,0 +1,191 @@ +/** + * @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/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts new file mode 100644 index 00000000..e75e1bd1 --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; +import type { DiffManager } from '../diff-manager.js'; +import type { WebViewProvider } from '../webview/WebViewProvider.js'; + +type Logger = (message: string) => void; + +export const runQwenCodeCommand = 'qwen-code.runQwenCode'; +export const showDiffCommand = 'qwenCode.showDiff'; +export const openChatCommand = 'qwen-code.openChat'; +export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; +export const loginCommand = 'qwen-code.login'; + +export function registerNewCommands( + context: vscode.ExtensionContext, + log: Logger, + diffManager: DiffManager, + getWebViewProviders: () => WebViewProvider[], + createWebViewProvider: () => WebViewProvider, +): void { + const disposables: vscode.Disposable[] = []; + + disposables.push( + vscode.commands.registerCommand(openChatCommand, async () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + await providers[providers.length - 1].show(); + } else { + const provider = createWebViewProvider(); + await provider.show(); + } + }), + ); + + disposables.push( + vscode.commands.registerCommand( + showDiffCommand, + async (args: { path: string; oldText: string; newText: string }) => { + try { + let absolutePath = args.path; + if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + absolutePath = vscode.Uri.joinPath( + workspaceFolder.uri, + args.path, + ).fsPath; + } + } + log(`[Command] Showing diff for ${absolutePath}`); + await diffManager.showDiff(absolutePath, args.oldText, args.newText); + } catch (error) { + log(`[Command] Error showing diff: ${error}`); + vscode.window.showErrorMessage(`Failed to show diff: ${error}`); + } + }, + ), + ); + + disposables.push( + vscode.commands.registerCommand(openNewChatTabCommand, async () => { + const provider = createWebViewProvider(); + // Session restoration is now disabled by default, so no need to suppress it + await provider.show(); + }), + ); + + disposables.push( + vscode.commands.registerCommand(loginCommand, async () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + await providers[providers.length - 1].forceReLogin(); + } else { + vscode.window.showInformationMessage( + 'Please open Qwen Code chat first before logging in.', + ); + } + }), + ); + context.subscriptions.push(...disposables); +} diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts new file mode 100644 index 00000000..18a69641 --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const AGENT_METHODS = { + authenticate: 'authenticate', + initialize: 'initialize', + session_cancel: 'session/cancel', + session_list: 'session/list', + session_load: 'session/load', + session_new: 'session/new', + session_prompt: 'session/prompt', + session_save: 'session/save', + session_set_mode: 'session/set_mode', +} as const; + +export const CLIENT_METHODS = { + fs_read_text_file: 'fs/read_text_file', + fs_write_text_file: 'fs/write_text_file', + session_request_permission: 'session/request_permission', + session_update: 'session/update', +} as const; diff --git a/packages/vscode-ide-companion/src/constants/loadingMessages.ts b/packages/vscode-ide-companion/src/constants/loadingMessages.ts new file mode 100644 index 00000000..edb01ca2 --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/loadingMessages.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Loading messages from Qwen Code CLI + * Source: packages/cli/src/ui/hooks/usePhraseCycler.ts + */ +export const WITTY_LOADING_PHRASES = [ + "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...', + '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...', + "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...", + '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...', + '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?...', + "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...', + 'Trying to exit 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#.", + '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...', + '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...', + "New line? That's Ctrl+J.", +]; + +export const getRandomLoadingMessage = (): string => + WITTY_LOADING_PHRASES[ + Math.floor(Math.random() * WITTY_LOADING_PHRASES.length) + ]; diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index fea9edc4..9a32769c 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -12,6 +12,10 @@ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { DIFF_SCHEME } from './extension.js'; +import { + findLeftGroupOfChatWebview, + ensureLeftGroupOfChatWebview, +} from './utils/editorGroupUtils.js'; export class DiffContentProvider implements vscode.TextDocumentContentProvider { private content = new Map(); @@ -42,7 +46,9 @@ export class DiffContentProvider implements vscode.TextDocumentContentProvider { // Information about a diff view that is currently open. interface DiffInfo { originalFilePath: string; + oldContent: string; newContent: string; + leftDocUri: vscode.Uri; rightDocUri: vscode.Uri; } @@ -55,11 +61,26 @@ export class DiffManager { readonly onDidChange = this.onDidChangeEmitter.event; private diffDocuments = new Map(); private readonly subscriptions: vscode.Disposable[] = []; + // Dedupe: remember recent showDiff calls keyed by (file+content) + private recentlyShown = new Map(); + private pendingDelayTimers = new Map(); + private static readonly DEDUPE_WINDOW_MS = 1500; + // Optional hooks from extension to influence diff behavior + // - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open) + // - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode) + private shouldDelay?: () => boolean; + private shouldSuppress?: () => boolean; + // Timed suppression window (e.g. immediately after permission allow) + private suppressUntil: number | null = null; constructor( private readonly log: (message: string) => void, private readonly diffContentProvider: DiffContentProvider, + shouldDelay?: () => boolean, + shouldSuppress?: () => boolean, ) { + this.shouldDelay = shouldDelay; + this.shouldSuppress = shouldSuppress; this.subscriptions.push( vscode.window.onDidChangeActiveTextEditor((editor) => { this.onActiveEditorChange(editor); @@ -75,43 +96,142 @@ export class DiffManager { } /** - * Creates and shows a new diff view. + * Checks if a diff view already exists for the given file path and content + * @param filePath Path to the file being diffed + * @param oldContent The original content (left side) + * @param newContent The modified content (right side) + * @returns True if a diff view with the same content already exists, false otherwise */ - async showDiff(filePath: string, newContent: string) { - const fileUri = vscode.Uri.file(filePath); + private hasExistingDiff( + filePath: string, + oldContent: string, + newContent: string, + ): boolean { + for (const diffInfo of this.diffDocuments.values()) { + if ( + diffInfo.originalFilePath === filePath && + diffInfo.oldContent === oldContent && + diffInfo.newContent === newContent + ) { + return true; + } + } + return false; + } + /** + * Finds an existing diff view for the given file path and focuses it + * @param filePath Path to the file being diffed + * @returns True if an existing diff view was found and focused, false otherwise + */ + private async focusExistingDiff(filePath: string): Promise { + const normalizedPath = path.normalize(filePath); + for (const [, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === normalizedPath) { + const rightDocUri = diffInfo.rightDocUri; + const leftDocUri = diffInfo.leftDocUri; + + const diffTitle = `${path.basename(filePath)} (Before ↔ After)`; + + try { + await vscode.commands.executeCommand( + 'vscode.diff', + leftDocUri, + rightDocUri, + diffTitle, + { + viewColumn: vscode.ViewColumn.Beside, + preview: false, + preserveFocus: true, + }, + ); + return true; + } catch (error) { + this.log(`Failed to focus existing diff: ${error}`); + return false; + } + } + } + return false; + } + + /** + * Creates and shows a new diff view. + * - Overload 1: showDiff(filePath, newContent) + * - Overload 2: showDiff(filePath, oldContent, newContent) + * If only newContent is provided, the old content will be read from the + * filesystem (empty string when file does not exist). + */ + async showDiff(filePath: string, newContent: string): Promise; + async showDiff( + filePath: string, + oldContent: string, + newContent: string, + ): Promise; + async showDiff(filePath: string, a: string, b?: string): Promise { + const haveOld = typeof b === 'string'; + const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath); + const newContent = haveOld ? (b as string) : a; + const normalizedPath = path.normalize(filePath); + const key = this.makeKey(normalizedPath, oldContent, newContent); + + // Check if a diff view with the same content already exists + if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) { + const last = this.recentlyShown.get(key) || 0; + const now = Date.now(); + if (now - last < DiffManager.DEDUPE_WINDOW_MS) { + // Within dedupe window: ignore the duplicate request entirely + this.log( + `Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`, + ); + return; + } + // Outside the dedupe window: softly focus the existing diff + await this.focusExistingDiff(normalizedPath); + this.recentlyShown.set(key, now); + return; + } + // Left side: old content using qwen-diff scheme + const leftDocUri = vscode.Uri.from({ + scheme: DIFF_SCHEME, + path: normalizedPath, + query: `old&rand=${Math.random()}`, + }); + this.diffContentProvider.setContent(leftDocUri, oldContent); + + // Right side: new content using qwen-diff scheme const rightDocUri = vscode.Uri.from({ scheme: DIFF_SCHEME, - path: filePath, - // cache busting - query: `rand=${Math.random()}`, + path: normalizedPath, + query: `new&rand=${Math.random()}`, }); this.diffContentProvider.setContent(rightDocUri, newContent); this.addDiffDocument(rightDocUri, { - originalFilePath: filePath, + originalFilePath: normalizedPath, + oldContent, newContent, + leftDocUri, rightDocUri, }); - const diffTitle = `${path.basename(filePath)} ↔ Modified`; + const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`; await vscode.commands.executeCommand( 'setContext', 'qwen.diff.isVisible', true, ); - let leftDocUri; - try { - await vscode.workspace.fs.stat(fileUri); - leftDocUri = fileUri; - } catch { - // We need to provide an empty document to diff against. - // Using the 'untitled' scheme is one way to do this. - leftDocUri = vscode.Uri.from({ - scheme: 'untitled', - path: filePath, - }); + // Prefer opening the diff adjacent to the chat webview (so we don't + // replace content inside the locked webview group). We try the group to + // the left of the chat webview first; if none exists we fall back to + // ViewColumn.Beside. With the chat locked in the leftmost group, this + // fallback opens diffs to the right of the chat. + let targetViewColumn = findLeftGroupOfChatWebview(); + if (targetViewColumn === undefined) { + // If there is no left neighbor, create one to satisfy the requirement of + // opening diffs to the left of the chat webview. + targetViewColumn = await ensureLeftGroupOfChatWebview(); } await vscode.commands.executeCommand( @@ -120,6 +240,10 @@ export class DiffManager { rightDocUri, diffTitle, { + // If a left-of-webview group was found, target it explicitly so the + // diff opens there while keeping focus on the webview. Otherwise, use + // the default "open to side" behavior. + viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside, preview: false, preserveFocus: true, }, @@ -127,16 +251,19 @@ export class DiffManager { await vscode.commands.executeCommand( 'workbench.action.files.setActiveEditorWriteableInSession', ); + + this.recentlyShown.set(key, Date.now()); } /** * Closes an open diff view for a specific file. */ async closeDiff(filePath: string, suppressNotification = false) { + const normalizedPath = path.normalize(filePath); let uriToClose: vscode.Uri | undefined; - for (const [uriString, diffInfo] of this.diffDocuments.entries()) { - if (diffInfo.originalFilePath === filePath) { - uriToClose = vscode.Uri.parse(uriString); + for (const [, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === normalizedPath) { + uriToClose = diffInfo.rightDocUri; break; } } @@ -267,4 +394,40 @@ export class DiffManager { } } } + + /** Close all open qwen-diff editors */ + async closeAll(): Promise { + // Collect keys first to avoid iterator invalidation while closing + const uris = Array.from(this.diffDocuments.keys()).map((k) => + vscode.Uri.parse(k), + ); + for (const uri of uris) { + try { + await this.closeDiffEditor(uri); + } catch (err) { + this.log(`Failed to close diff editor: ${err}`); + } + } + } + + // Read the current content of file from the workspace; return empty string if not found + private async readOldContentFromFs(filePath: string): Promise { + try { + const fileUri = vscode.Uri.file(filePath); + const document = await vscode.workspace.openTextDocument(fileUri); + return document.getText(); + } catch { + return ''; + } + } + + private makeKey(filePath: string, oldContent: string, newContent: string) { + // Simple stable key; content could be large but kept transiently + return `${filePath}\u241F${oldContent}\u241F${newContent}`; + } + + /** Temporarily suppress opening diffs for a short duration. */ + suppressFor(durationMs: number): void { + this.suppressUntil = Date.now() + Math.max(0, durationMs); + } } diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index 2560881d..31d5aa52 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -40,6 +40,9 @@ vi.mock('vscode', () => ({ }, showTextDocument: vi.fn(), showWorkspaceFolderPick: vi.fn(), + registerWebviewPanelSerializer: vi.fn(() => ({ + dispose: vi.fn(), + })), }, workspace: { workspaceFolders: [], diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 8e2344a9..2adfaef1 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -14,6 +14,8 @@ import { IDE_DEFINITIONS, type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; +import { WebViewProvider } from './webview/WebViewProvider.js'; +import { registerNewCommands } from './commands/index.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; @@ -31,6 +33,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; +let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs let log: (message: string) => void = () => {}; @@ -108,7 +111,75 @@ export async function activate(context: vscode.ExtensionContext) { checkForUpdates(context, log); const diffContentProvider = new DiffContentProvider(); - const diffManager = new DiffManager(log, diffContentProvider); + const diffManager = new DiffManager( + log, + diffContentProvider, + // Delay when any chat tab has a pending permission drawer + () => webViewProviders.some((p) => p.hasPendingPermission()), + // Suppress diffs when active mode is auto or yolo in any chat tab + () => { + const providers = webViewProviders.filter( + (p) => typeof p.shouldSuppressDiff === 'function', + ); + if (providers.length === 0) { + return false; + } + return providers.every((p) => p.shouldSuppressDiff()); + }, + ); + + // Helper function to create a new WebView provider instance + const createWebViewProvider = (): WebViewProvider => { + const provider = new WebViewProvider(context, context.extensionUri); + webViewProviders.push(provider); + return provider; + }; + + // Register WebView panel serializer for persistence across reloads + context.subscriptions.push( + vscode.window.registerWebviewPanelSerializer('qwenCode.chat', { + async deserializeWebviewPanel( + webviewPanel: vscode.WebviewPanel, + state: unknown, + ) { + console.log( + '[Extension] Deserializing WebView panel with state:', + state, + ); + + // Create a new provider for the restored panel + const provider = createWebViewProvider(); + console.log('[Extension] Provider created for deserialization'); + + // Restore state if available BEFORE restoring the panel + if (state && typeof state === 'object') { + console.log('[Extension] Restoring state:', state); + provider.restoreState( + state as { + conversationId: string | null; + agentInitialized: boolean; + }, + ); + } else { + console.log('[Extension] No state to restore or invalid state'); + } + + await provider.restorePanel(webviewPanel); + console.log('[Extension] Panel restore completed'); + + log('WebView panel restored from serialization'); + }, + }), + ); + + // Register newly added commands via commands module + registerNewCommands( + context, + log, + diffManager, + () => webViewProviders, + createWebViewProvider, + ); context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { @@ -120,17 +191,53 @@ export async function activate(context: vscode.ExtensionContext) { DIFF_SCHEME, diffContentProvider, ), - vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => { + (vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.acceptDiff(docUri); } + // If WebView is requesting permission, actively select an allow option (prefer once) + try { + for (const provider of webViewProviders) { + if (provider?.hasPendingPermission()) { + provider.respondToPendingPermission('allow'); + } + } + } catch (err) { + console.warn('[Extension] Auto-allow on diff.accept failed:', err); + } + console.log('[Extension] Diff accepted'); }), vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.cancelDiff(docUri); } + // If WebView is requesting permission, actively select reject/cancel + try { + for (const provider of webViewProviders) { + if (provider?.hasPendingPermission()) { + provider.respondToPendingPermission('cancel'); + } + } + } catch (err) { + console.warn('[Extension] Auto-reject on diff.cancel failed:', err); + } + console.log('[Extension] Diff cancelled'); + })), + vscode.commands.registerCommand('qwen.diff.closeAll', async () => { + try { + await diffManager.closeAll(); + } catch (err) { + console.warn('[Extension] qwen.diff.closeAll failed:', err); + } + }), + vscode.commands.registerCommand('qwen.diff.suppressBriefly', async () => { + try { + diffManager.suppressFor(1200); + } catch (err) { + console.warn('[Extension] qwen.diff.suppressBriefly failed:', err); + } }), ); @@ -160,34 +267,42 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.onDidGrantWorkspaceTrust(() => { ideServer.syncEnvVars(); }), - vscode.commands.registerCommand('qwen-code.runQwenCode', async () => { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showInformationMessage( - 'No folder open. Please open a folder to run Qwen Code.', - ); - return; - } + vscode.commands.registerCommand( + 'qwen-code.runQwenCode', + async ( + location?: + | vscode.TerminalLocation + | vscode.TerminalEditorLocationOptions, + ) => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showInformationMessage( + 'No folder open. Please open a folder to run Qwen Code.', + ); + return; + } - let selectedFolder: vscode.WorkspaceFolder | undefined; - if (workspaceFolders.length === 1) { - selectedFolder = workspaceFolders[0]; - } else { - selectedFolder = await vscode.window.showWorkspaceFolderPick({ - placeHolder: 'Select a folder to run Qwen Code in', - }); - } + let selectedFolder: vscode.WorkspaceFolder | undefined; + if (workspaceFolders.length === 1) { + selectedFolder = workspaceFolders[0]; + } else { + selectedFolder = await vscode.window.showWorkspaceFolderPick({ + placeHolder: 'Select a folder to run Qwen Code in', + }); + } - if (selectedFolder) { - const qwenCmd = 'qwen'; - const terminal = vscode.window.createTerminal({ - name: `Qwen Code (${selectedFolder.name})`, - cwd: selectedFolder.uri.fsPath, - }); - terminal.show(); - terminal.sendText(qwenCmd); - } - }), + if (selectedFolder) { + const qwenCmd = 'qwen'; + const terminal = vscode.window.createTerminal({ + name: `Qwen Code (${selectedFolder.name})`, + cwd: selectedFolder.uri.fsPath, + location, + }); + terminal.show(); + terminal.sendText(qwenCmd); + } + }, + ), vscode.commands.registerCommand('qwen-code.showNotices', async () => { const noticePath = vscode.Uri.joinPath( context.extensionUri, @@ -204,6 +319,11 @@ export async function deactivate(): Promise { if (ideServer) { await ideServer.stop(); } + // Dispose all WebView providers + webViewProviders.forEach((provider) => { + provider.dispose(); + }); + webViewProviders = []; } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`Failed to stop IDE server during deactivation: ${message}`); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index e67dfa81..8324f802 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -437,6 +437,7 @@ const createMcpServer = (diffManager: DiffManager) => { inputSchema: OpenDiffRequestSchema.shape, }, async ({ filePath, newContent }: z.infer) => { + // Minimal call site: only pass newContent; DiffManager reads old content itself await diffManager.showDiff(filePath, newContent); return { content: [] }; }, diff --git a/packages/vscode-ide-companion/src/open-files-manager.test.ts b/packages/vscode-ide-companion/src/open-files-manager.test.ts index 0b1ada82..74d18ffa 100644 --- a/packages/vscode-ide-companion/src/open-files-manager.test.ts +++ b/packages/vscode-ide-companion/src/open-files-manager.test.ts @@ -414,7 +414,7 @@ describe('OpenFilesManager', () => { await vi.advanceTimersByTimeAsync(100); file1 = manager.state.workspaceState!.openFiles!.find( - (f) => f.path === '/test/file1.txt', + (f: { path: string }) => f.path === '/test/file1.txt', )!; const file2 = manager.state.workspaceState!.openFiles![0]; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts new file mode 100644 index 00000000..5486e14d --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -0,0 +1,426 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { JSONRPC_VERSION } from '../types/acpTypes.js'; +import type { + AcpMessage, + AcpPermissionRequest, + AcpResponse, + AcpSessionUpdate, + ApprovalModeValue, +} from '../types/acpTypes.js'; +import type { ChildProcess, SpawnOptions } from 'child_process'; +import { spawn } from 'child_process'; +import type { + PendingRequest, + AcpConnectionCallbacks, +} from '../types/connectionTypes.js'; +import { AcpMessageHandler } from './acpMessageHandler.js'; +import { AcpSessionManager } from './acpSessionManager.js'; +import { determineNodePathForCli } from '../cli/cliPathDetector.js'; + +/** + * ACP Connection Handler for VSCode Extension + * + * This class implements the client side of the ACP (Agent Communication Protocol). + */ +export class AcpConnection { + private child: ChildProcess | null = null; + private pendingRequests = new Map>(); + private nextRequestId = { value: 0 }; + // Remember the working dir provided at connect() so later ACP calls + // that require cwd (e.g. session/list) can include it. + private workingDir: string = process.cwd(); + + private messageHandler: AcpMessageHandler; + private sessionManager: AcpSessionManager; + + onSessionUpdate: (data: AcpSessionUpdate) => void = () => {}; + onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + optionId: string; + }> = () => Promise.resolve({ optionId: 'allow' }); + onEndTurn: () => void = () => {}; + // Called after successful initialize() with the initialize result + onInitialized: (init: unknown) => void = () => {}; + + constructor() { + this.messageHandler = new AcpMessageHandler(); + this.sessionManager = new AcpSessionManager(); + } + + /** + * Connect to Qwen ACP + * + * @param cliPath - CLI path + * @param workingDir - Working directory + * @param extraArgs - Extra command line arguments + */ + async connect( + cliPath: string, + workingDir: string = process.cwd(), + extraArgs: string[] = [], + ): Promise { + if (this.child) { + this.disconnect(); + } + + this.workingDir = workingDir; + + const isWindows = process.platform === 'win32'; + const env = { ...process.env }; + + // If proxy is configured in extraArgs, also set it as environment variable + // This ensures token refresh requests also use the proxy + const proxyArg = extraArgs.find( + (arg, i) => arg === '--proxy' && i + 1 < extraArgs.length, + ); + if (proxyArg) { + const proxyIndex = extraArgs.indexOf('--proxy'); + const proxyUrl = extraArgs[proxyIndex + 1]; + console.log('[ACP] Setting proxy environment variables:', proxyUrl); + + env['HTTP_PROXY'] = proxyUrl; + env['HTTPS_PROXY'] = proxyUrl; + env['http_proxy'] = proxyUrl; + env['https_proxy'] = proxyUrl; + } + + let spawnCommand: string; + let spawnArgs: string[]; + + 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]; + } + } + + console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' ')); + + const options: SpawnOptions = { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env, + shell: isWindows, + }; + + this.child = spawn(spawnCommand, spawnArgs, options); + await this.setupChildProcessHandlers(); + } + + /** + * Set up child process handlers + */ + private async setupChildProcessHandlers(): Promise { + let spawnError: Error | null = null; + + this.child!.stderr?.on('data', (data) => { + const message = data.toString(); + if ( + message.toLowerCase().includes('error') && + !message.includes('Loaded cached') + ) { + console.error(`[ACP qwen]:`, message); + } else { + console.log(`[ACP qwen]:`, message); + } + }); + + this.child!.on('error', (error) => { + spawnError = error; + }); + + this.child!.on('exit', (code, signal) => { + console.error( + `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, + ); + }); + + // Wait for process to start + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (spawnError) { + throw spawnError; + } + + if (!this.child || this.child.killed) { + throw new Error(`Qwen ACP process failed to start`); + } + + // Handle messages from ACP server + let buffer = ''; + this.child.stdout?.on('data', (data) => { + buffer += data.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const message = JSON.parse(line) as AcpMessage; + console.log( + '[ACP] <<< Received message:', + JSON.stringify(message).substring(0, 500 * 3), + ); + this.handleMessage(message); + } catch (_error) { + // Ignore non-JSON lines + console.log( + '[ACP] <<< Non-JSON line (ignored):', + line.substring(0, 200), + ); + } + } + } + }); + + // Initialize protocol + const res = await this.sessionManager.initialize( + this.child, + this.pendingRequests, + this.nextRequestId, + ); + + console.log('[ACP] Initialization response:', res); + try { + this.onInitialized(res); + } catch (err) { + console.warn('[ACP] onInitialized callback error:', err); + } + } + + /** + * Handle received messages + * + * @param message - ACP message + */ + private handleMessage(message: AcpMessage): void { + const callbacks: AcpConnectionCallbacks = { + onSessionUpdate: this.onSessionUpdate, + onPermissionRequest: this.onPermissionRequest, + onEndTurn: this.onEndTurn, + }; + + // Handle message + if ('method' in message) { + // Request or notification + this.messageHandler + .handleIncomingRequest(message, callbacks) + .then((result) => { + if ('id' in message && typeof message.id === 'number') { + this.messageHandler.sendResponseMessage(this.child, { + jsonrpc: JSONRPC_VERSION, + id: message.id, + result, + }); + } + }) + .catch((error) => { + if ('id' in message && typeof message.id === 'number') { + this.messageHandler.sendResponseMessage(this.child, { + jsonrpc: JSONRPC_VERSION, + id: message.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + }); + } + }); + } else { + // Response + this.messageHandler.handleMessage( + message, + this.pendingRequests, + callbacks, + ); + } + } + + /** + * Authenticate + * + * @param methodId - Authentication method ID + * @returns Authentication response + */ + async authenticate(methodId?: string): Promise { + return this.sessionManager.authenticate( + methodId, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Create new session + * + * @param cwd - Working directory + * @returns New session response + */ + async newSession(cwd: string = process.cwd()): Promise { + return this.sessionManager.newSession( + cwd, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Send prompt message + * + * @param prompt - Prompt content + * @returns Response + */ + async sendPrompt(prompt: string): Promise { + return this.sessionManager.sendPrompt( + prompt, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Load existing session + * + * @param sessionId - Session ID + * @returns Load response + */ + async loadSession( + sessionId: string, + cwdOverride?: string, + ): Promise { + return this.sessionManager.loadSession( + sessionId, + this.child, + this.pendingRequests, + this.nextRequestId, + cwdOverride || this.workingDir, + ); + } + + /** + * Get session list + * + * @returns Session list response + */ + async listSessions(options?: { + cursor?: number; + size?: number; + }): Promise { + return this.sessionManager.listSessions( + this.child, + this.pendingRequests, + this.nextRequestId, + this.workingDir, + options, + ); + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + * @returns Switch response + */ + async switchSession(sessionId: string): Promise { + return this.sessionManager.switchSession(sessionId, this.nextRequestId); + } + + /** + * Cancel current session prompt generation + */ + async cancelSession(): Promise { + await this.sessionManager.cancelSession(this.child); + } + + /** + * Save current session + * + * @param tag - Save tag + * @returns Save response + */ + async saveSession(tag: string): Promise { + return this.sessionManager.saveSession( + tag, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Set approval mode + */ + async setMode(modeId: ApprovalModeValue): Promise { + return this.sessionManager.setMode( + modeId, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Disconnect + */ + disconnect(): void { + if (this.child) { + this.child.kill(); + this.child = null; + } + + this.pendingRequests.clear(); + this.sessionManager.reset(); + } + + /** + * Check if connected + */ + get isConnected(): boolean { + return this.child !== null && !this.child.killed; + } + + /** + * Check if there is an active session + */ + get hasActiveSession(): boolean { + return this.sessionManager.getCurrentSessionId() !== null; + } + + /** + * Get current session ID + */ + get currentSessionId(): string | null { + return this.sessionManager.getCurrentSessionId(); + } +} diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.ts new file mode 100644 index 00000000..8dce3c7b --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP File Operation Handler + * + * Responsible for handling file read and write operations in the ACP protocol + */ + +import { promises as fs } from 'fs'; +import * as path from 'path'; + +/** + * ACP File Operation Handler Class + * Provides file read and write functionality according to ACP protocol specifications + */ +export class AcpFileHandler { + /** + * Handle read text file request + * + * @param params - File read parameters + * @param params.path - File path + * @param params.sessionId - Session ID + * @param params.line - Starting line number (optional) + * @param params.limit - Read line limit (optional) + * @returns File content + * @throws Error when file reading fails + */ + async handleReadTextFile(params: { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }): Promise<{ content: string }> { + console.log(`[ACP] fs/read_text_file request received for: ${params.path}`); + console.log(`[ACP] Parameters:`, { + line: params.line, + limit: params.limit, + sessionId: params.sessionId, + }); + + try { + const content = await fs.readFile(params.path, 'utf-8'); + console.log( + `[ACP] Successfully read file: ${params.path} (${content.length} bytes)`, + ); + + // Handle line offset and limit + if (params.line !== null || params.limit !== null) { + const lines = content.split('\n'); + const startLine = params.line || 0; + const endLine = params.limit ? startLine + params.limit : lines.length; + const selectedLines = lines.slice(startLine, endLine); + const result = { content: selectedLines.join('\n') }; + console.log(`[ACP] Returning ${selectedLines.length} lines`); + return result; + } + + const result = { content }; + console.log(`[ACP] Returning full file content`); + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg); + + throw new Error(`Failed to read file '${params.path}': ${errorMsg}`); + } + } + + /** + * Handle write text file request + * + * @param params - File write parameters + * @param params.path - File path + * @param params.content - File content + * @param params.sessionId - Session ID + * @returns null indicates success + * @throws Error when file writing fails + */ + async handleWriteTextFile(params: { + path: string; + content: string; + sessionId: string; + }): Promise { + console.log( + `[ACP] fs/write_text_file request received for: ${params.path}`, + ); + console.log(`[ACP] Content size: ${params.content.length} bytes`); + + try { + // Ensure directory exists + const dirName = path.dirname(params.path); + console.log(`[ACP] Ensuring directory exists: ${dirName}`); + await fs.mkdir(dirName, { recursive: true }); + + // Write file + await fs.writeFile(params.path, params.content, 'utf-8'); + + console.log(`[ACP] Successfully wrote file: ${params.path}`); + return null; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg); + + throw new Error(`Failed to write file '${params.path}': ${errorMsg}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts new file mode 100644 index 00000000..db7802ce --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP Message Handler + * + * Responsible for receiving, parsing, and distributing messages in the ACP protocol + */ + +import type { + AcpMessage, + AcpRequest, + AcpNotification, + AcpResponse, + AcpSessionUpdate, + AcpPermissionRequest, +} from '../types/acpTypes.js'; +import { CLIENT_METHODS } from '../constants/acpSchema.js'; +import type { + PendingRequest, + AcpConnectionCallbacks, +} from '../types/connectionTypes.js'; +import { AcpFileHandler } from '../services/acpFileHandler.js'; +import type { ChildProcess } from 'child_process'; + +/** + * ACP Message Handler Class + * Responsible for receiving, parsing, and processing messages + */ +export class AcpMessageHandler { + private fileHandler: AcpFileHandler; + + constructor() { + this.fileHandler = new AcpFileHandler(); + } + + /** + * Send response message to child process + * + * @param child - Child process instance + * @param response - Response message + */ + sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void { + if (child?.stdin) { + const jsonString = JSON.stringify(response); + const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + child.stdin.write(jsonString + lineEnding); + } + } + + /** + * Handle received messages + * + * @param message - ACP message + * @param pendingRequests - Pending requests map + * @param callbacks - Callback functions collection + */ + handleMessage( + message: AcpMessage, + pendingRequests: Map>, + callbacks: AcpConnectionCallbacks, + ): void { + try { + if ('method' in message) { + // Request or notification + this.handleIncomingRequest(message, callbacks).catch(() => {}); + } else if ( + 'id' in message && + typeof message.id === 'number' && + pendingRequests.has(message.id) + ) { + // Response + this.handleResponse(message, pendingRequests, callbacks); + } + } catch (error) { + console.error('[ACP] Error handling message:', error); + } + } + + /** + * Handle response message + * + * @param message - Response message + * @param pendingRequests - Pending requests map + * @param callbacks - Callback functions collection + */ + private handleResponse( + message: AcpMessage, + pendingRequests: Map>, + callbacks: AcpConnectionCallbacks, + ): void { + if (!('id' in message) || typeof message.id !== 'number') { + return; + } + + const pendingRequest = pendingRequests.get(message.id); + if (!pendingRequest) { + return; + } + + const { resolve, reject, method } = pendingRequest; + pendingRequests.delete(message.id); + + if ('result' in message) { + console.log( + `[ACP] Response for ${method}:`, + // 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(); + } + resolve(message.result); + } else if ('error' in message) { + const errorCode = message.error?.code || 'unknown'; + const errorMsg = message.error?.message || 'Unknown ACP error'; + const errorData = message.error?.data + ? JSON.stringify(message.error.data) + : ''; + console.error(`[ACP] Error response for ${method}:`, { + code: errorCode, + message: errorMsg, + data: errorData, + }); + reject( + new Error( + `${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`, + ), + ); + } + } + + /** + * Handle incoming requests + * + * @param message - Request or notification message + * @param callbacks - Callback functions collection + * @returns Request processing result + */ + async handleIncomingRequest( + message: AcpRequest | AcpNotification, + callbacks: AcpConnectionCallbacks, + ): Promise { + const { method, params } = message; + + let result = null; + + switch (method) { + case CLIENT_METHODS.session_update: + console.log( + '[ACP] >>> Processing session_update:', + JSON.stringify(params).substring(0, 300), + ); + callbacks.onSessionUpdate(params as AcpSessionUpdate); + break; + case CLIENT_METHODS.session_request_permission: + result = await this.handlePermissionRequest( + params as AcpPermissionRequest, + callbacks, + ); + break; + case CLIENT_METHODS.fs_read_text_file: + result = await this.fileHandler.handleReadTextFile( + params as { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }, + ); + break; + case CLIENT_METHODS.fs_write_text_file: + result = await this.fileHandler.handleWriteTextFile( + params as { path: string; content: string; sessionId: string }, + ); + break; + default: + console.warn(`[ACP] Unhandled method: ${method}`); + break; + } + + return result; + } + + /** + * Handle permission requests + * + * @param params - Permission request parameters + * @param callbacks - Callback functions collection + * @returns Permission request result + */ + private async handlePermissionRequest( + params: AcpPermissionRequest, + callbacks: AcpConnectionCallbacks, + ): Promise<{ + outcome: { outcome: string; optionId: string }; + }> { + try { + const response = await callbacks.onPermissionRequest(params); + const optionId = response?.optionId; + console.log('[ACP] Permission request:', optionId); + // Handle cancel, deny, or allow + let outcome: string; + if (optionId && (optionId.includes('reject') || optionId === 'cancel')) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + console.log('[ACP] Permission outcome:', outcome); + + return { + outcome: { + outcome, + // optionId: optionId === 'cancel' ? 'cancel' : optionId, + optionId, + }, + }; + } catch (_error) { + return { + outcome: { + outcome: 'rejected', + optionId: 'reject_once', + }, + }; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts new file mode 100644 index 00000000..8812282a --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -0,0 +1,474 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP Session Manager + * + * Responsible for managing ACP protocol session operations, including initialization, authentication, session creation, and switching + */ +import { JSONRPC_VERSION } from '../types/acpTypes.js'; +import type { + AcpRequest, + AcpNotification, + AcpResponse, + ApprovalModeValue, +} from '../types/acpTypes.js'; +import { AGENT_METHODS } from '../constants/acpSchema.js'; +import type { PendingRequest } from '../types/connectionTypes.js'; +import type { ChildProcess } from 'child_process'; + +/** + * ACP Session Manager Class + * Provides session initialization, authentication, creation, loading, and switching functionality + */ +export class AcpSessionManager { + private sessionId: string | null = null; + private isInitialized = false; + + /** + * Send request to ACP server + * + * @param method - Request method name + * @param params - Request parameters + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Request response + */ + private sendRequest( + method: string, + params: Record | undefined, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const id = nextRequestId.value++; + const message: AcpRequest = { + jsonrpc: JSONRPC_VERSION, + id, + method, + ...(params && { params }), + }; + + return new Promise((resolve, reject) => { + const timeoutDuration = + method === AGENT_METHODS.session_prompt ? 120000 : 60000; + + const timeoutId = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error(`Request ${method} timed out`)); + }, timeoutDuration); + + const pendingRequest: PendingRequest = { + resolve: (value: T) => { + clearTimeout(timeoutId); + resolve(value); + }, + reject: (error: Error) => { + clearTimeout(timeoutId); + reject(error); + }, + timeoutId, + method, + }; + + pendingRequests.set(id, pendingRequest as PendingRequest); + this.sendMessage(message, child); + }); + } + + /** + * Send message to child process + * + * @param message - Request or notification message + * @param child - Child process instance + */ + private sendMessage( + message: AcpRequest | AcpNotification, + child: ChildProcess | null, + ): void { + if (child?.stdin) { + const jsonString = JSON.stringify(message); + const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + child.stdin.write(jsonString + lineEnding); + } + } + + /** + * Initialize ACP protocol connection + * + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Initialization response + */ + async initialize( + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const initializeParams = { + protocolVersion: 1, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }; + + console.log('[ACP] Sending initialize request...'); + const response = await this.sendRequest( + AGENT_METHODS.initialize, + initializeParams, + child, + pendingRequests, + nextRequestId, + ); + this.isInitialized = true; + + console.log('[ACP] Initialize successful'); + return response; + } + + /** + * Perform authentication + * + * @param methodId - Authentication method ID + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Authentication response + */ + async authenticate( + methodId: string | undefined, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const authMethodId = methodId || 'default'; + console.log( + '[ACP] Sending authenticate request with methodId:', + authMethodId, + ); + const response = await this.sendRequest( + AGENT_METHODS.authenticate, + { + methodId: authMethodId, + }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] Authenticate successful'); + return response; + } + + /** + * Create new session + * + * @param cwd - Working directory + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns New session response + */ + async newSession( + cwd: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + console.log('[ACP] Sending session/new request with cwd:', cwd); + const response = await this.sendRequest< + AcpResponse & { sessionId?: string } + >( + AGENT_METHODS.session_new, + { + cwd, + mcpServers: [], + }, + child, + pendingRequests, + nextRequestId, + ); + + this.sessionId = (response && response.sessionId) || null; + console.log('[ACP] Session created with ID:', this.sessionId); + return response; + } + + /** + * Send prompt message + * + * @param prompt - Prompt content + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Response + * @throws Error when there is no active session + */ + async sendPrompt( + prompt: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + + return await this.sendRequest( + AGENT_METHODS.session_prompt, + { + sessionId: this.sessionId, + prompt: [{ type: 'text', text: prompt }], + }, + child, + pendingRequests, + nextRequestId, + ); + } + + /** + * Load existing session + * + * @param sessionId - Session ID + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Load response + */ + async loadSession( + sessionId: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + cwd: string = process.cwd(), + ): Promise { + console.log('[ACP] Sending session/load request for session:', sessionId); + console.log('[ACP] Request parameters:', { + sessionId, + cwd, + mcpServers: [], + }); + + try { + const response = await this.sendRequest( + AGENT_METHODS.session_load, + { + sessionId, + cwd, + mcpServers: [], + }, + child, + pendingRequests, + nextRequestId, + ); + + console.log( + '[ACP] Session load response:', + JSON.stringify(response).substring(0, 500), + ); + + // Check if response contains an error + if (response && response.error) { + console.error('[ACP] Session load returned error:', response.error); + } else { + console.log('[ACP] Session load succeeded'); + // session/load returns null on success per schema; update local sessionId + // so subsequent prompts use the loaded session. + this.sessionId = sessionId; + } + + return response; + } catch (error) { + console.error( + '[ACP] Session load request failed with exception:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Get session list + * + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Session list response + */ + async listSessions( + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + cwd: string = process.cwd(), + options?: { cursor?: number; size?: number }, + ): Promise { + console.log('[ACP] Requesting session list...'); + try { + // session/list requires cwd in params per ACP schema + const params: Record = { cwd }; + if (options?.cursor !== undefined) { + params.cursor = options.cursor; + } + if (options?.size !== undefined) { + params.size = options.size; + } + + const response = await this.sendRequest( + AGENT_METHODS.session_list, + params, + child, + pendingRequests, + nextRequestId, + ); + console.log( + '[ACP] Session list response:', + JSON.stringify(response).substring(0, 200), + ); + return response; + } catch (error) { + console.error('[ACP] Failed to get session list:', error); + throw error; + } + } + + /** + * Set approval mode for current session (ACP session/set_mode) + * + * @param modeId - Approval mode value + */ + async setMode( + modeId: ApprovalModeValue, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + console.log('[ACP] Sending session/set_mode:', modeId); + const res = await this.sendRequest( + AGENT_METHODS.session_set_mode, + { sessionId: this.sessionId, modeId }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] set_mode response:', res); + return res; + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + * @param nextRequestId - Request ID counter + * @returns Switch response + */ + async switchSession( + sessionId: string, + nextRequestId: { value: number }, + ): Promise { + console.log('[ACP] Switching to session:', sessionId); + this.sessionId = sessionId; + + const mockResponse: AcpResponse = { + jsonrpc: JSONRPC_VERSION, + id: nextRequestId.value++, + result: { sessionId }, + }; + console.log( + '[ACP] Session ID updated locally (switch not supported by CLI)', + ); + return mockResponse; + } + + /** + * Cancel prompt generation for current session + * + * @param child - Child process instance + */ + async cancelSession(child: ChildProcess | null): Promise { + if (!this.sessionId) { + console.warn('[ACP] No active session to cancel'); + return; + } + + console.log('[ACP] Cancelling session:', this.sessionId); + + const cancelParams = { + sessionId: this.sessionId, + }; + + const message: AcpNotification = { + jsonrpc: JSONRPC_VERSION, + method: AGENT_METHODS.session_cancel, + params: cancelParams, + }; + + this.sendMessage(message, child); + console.log('[ACP] Cancel notification sent'); + } + + /** + * Save current session + * + * @param tag - Save tag + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Save response + */ + async saveSession( + tag: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + + console.log('[ACP] Saving session with tag:', tag); + const response = await this.sendRequest( + AGENT_METHODS.session_save, + { + sessionId: this.sessionId, + tag, + }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] Session save response:', response); + return response; + } + + /** + * Reset session manager state + */ + reset(): void { + this.sessionId = null; + this.isInitialized = false; + } + + /** + * Get current session ID + */ + getCurrentSessionId(): string | null { + return this.sessionId; + } + + /** + * Check if initialized + */ + getIsInitialized(): boolean { + return this.isInitialized; + } +} diff --git a/packages/vscode-ide-companion/src/services/authStateManager.ts b/packages/vscode-ide-companion/src/services/authStateManager.ts new file mode 100644 index 00000000..566a4afb --- /dev/null +++ b/packages/vscode-ide-companion/src/services/authStateManager.ts @@ -0,0 +1,215 @@ +/** + * @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/conversationStore.ts b/packages/vscode-ide-companion/src/services/conversationStore.ts new file mode 100644 index 00000000..8a31af9c --- /dev/null +++ b/packages/vscode-ide-companion/src/services/conversationStore.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as vscode from 'vscode'; +import type { ChatMessage } from './qwenAgentManager.js'; + +export interface Conversation { + id: string; + title: string; + messages: ChatMessage[]; + createdAt: number; + updatedAt: number; +} + +export class ConversationStore { + private context: vscode.ExtensionContext; + private currentConversationId: string | null = null; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + } + + async createConversation(title: string = 'New Chat'): Promise { + const conversation: Conversation = { + id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + title, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const conversations = await this.getAllConversations(); + conversations.push(conversation); + await this.context.globalState.update('conversations', conversations); + + this.currentConversationId = conversation.id; + return conversation; + } + + async getAllConversations(): Promise { + return this.context.globalState.get('conversations', []); + } + + async getConversation(id: string): Promise { + const conversations = await this.getAllConversations(); + return conversations.find((c) => c.id === id) || null; + } + + async addMessage( + conversationId: string, + message: ChatMessage, + ): Promise { + const conversations = await this.getAllConversations(); + const conversation = conversations.find((c) => c.id === conversationId); + + if (conversation) { + conversation.messages.push(message); + conversation.updatedAt = Date.now(); + await this.context.globalState.update('conversations', conversations); + } + } + + async deleteConversation(id: string): Promise { + const conversations = await this.getAllConversations(); + const filtered = conversations.filter((c) => c.id !== id); + await this.context.globalState.update('conversations', filtered); + + if (this.currentConversationId === id) { + this.currentConversationId = null; + } + } + + getCurrentConversationId(): string | null { + return this.currentConversationId; + } + + setCurrentConversationId(id: string): void { + this.currentConversationId = id; + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts new file mode 100644 index 00000000..a57d15b7 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -0,0 +1,1411 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { AcpConnection } from './acpConnection.js'; +import type { + AcpSessionUpdate, + AcpPermissionRequest, + ApprovalModeValue, +} from '../types/acpTypes.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 { 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'; + +export type { ChatMessage, PlanEntry, ToolCallUpdateData }; + +/** + * Qwen Agent Manager + * + * Coordinates various modules and provides unified interface + */ +export class QwenAgentManager { + private connection: AcpConnection; + private sessionReader: QwenSessionReader; + private sessionManager: QwenSessionManager; + private connectionHandler: QwenConnectionHandler; + private sessionUpdateHandler: QwenSessionUpdateHandler; + private currentWorkingDir: string = process.cwd(); + // When loading a past session via ACP, the CLI replays history through + // 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; + + // Callback storage + private callbacks: QwenAgentCallbacks = {}; + + constructor() { + this.connection = new AcpConnection(); + this.sessionReader = new QwenSessionReader(); + this.sessionManager = new QwenSessionManager(); + this.connectionHandler = new QwenConnectionHandler(); + this.sessionUpdateHandler = new QwenSessionUpdateHandler({}); + + // Set ACP connection callbacks + this.connection.onSessionUpdate = (data: AcpSessionUpdate) => { + // If we are rehydrating a loaded session, map message chunks into + // full messages for the UI, instead of streaming behavior. + try { + const targetId = this.rehydratingSessionId; + if ( + targetId && + typeof data === 'object' && + data && + 'update' in data && + (data as { sessionId?: string }).sessionId === targetId + ) { + const update = ( + data as unknown as { + update: { sessionUpdate: string; content?: { text?: string } }; + } + ).update; + const text = update?.content?.text || ''; + if (update?.sessionUpdate === 'user_message_chunk' && text) { + console.log( + '[QwenAgentManager] Rehydration: routing user message chunk', + ); + this.callbacks.onMessage?.({ + role: 'user', + content: text, + timestamp: Date.now(), + }); + return; + } + if (update?.sessionUpdate === 'agent_message_chunk' && text) { + console.log( + '[QwenAgentManager] Rehydration: routing agent message chunk', + ); + this.callbacks.onMessage?.({ + role: 'assistant', + content: text, + timestamp: Date.now(), + }); + return; + } + // For other types during rehydration, fall through to normal handler + console.log( + '[QwenAgentManager] Rehydration: non-text update, forwarding to handler', + ); + } + } catch (err) { + console.warn('[QwenAgentManager] Rehydration routing failed:', err); + } + + // Default handling path + this.sessionUpdateHandler.handleSessionUpdate(data); + }; + + this.connection.onPermissionRequest = async ( + data: AcpPermissionRequest, + ) => { + if (this.callbacks.onPermissionRequest) { + const optionId = await this.callbacks.onPermissionRequest(data); + return { optionId }; + } + return { optionId: 'allow_once' }; + }; + + this.connection.onEndTurn = () => { + try { + if (this.callbacks.onEndTurn) { + this.callbacks.onEndTurn(); + } else if (this.callbacks.onStreamChunk) { + // Fallback: send a zero-length chunk then rely on streamEnd elsewhere + this.callbacks.onStreamChunk(''); + } + } catch (err) { + console.warn('[QwenAgentManager] onEndTurn callback error:', err); + } + }; + + // Initialize callback to surface available modes and current mode to UI + this.connection.onInitialized = (init: unknown) => { + try { + const obj = (init || {}) as Record; + const modes = obj['modes'] as + | { + currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; + availableModes?: Array<{ + id: 'plan' | 'default' | 'auto-edit' | 'yolo'; + name: string; + description: string; + }>; + } + | undefined; + if (modes && this.callbacks.onModeInfo) { + this.callbacks.onModeInfo({ + currentModeId: modes.currentModeId, + availableModes: modes.availableModes, + }); + } + } catch (err) { + console.warn('[QwenAgentManager] onInitialized parse error:', err); + } + }; + } + + /** + * 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) + */ + async connect( + workingDir: string, + authStateManager?: AuthStateManager, + _cliPath?: string, + ): Promise { + this.currentWorkingDir = workingDir; + // Remember the provided authStateManager for future calls + this.defaultAuthStateManager = authStateManager; + await this.connectionHandler.connect( + this.connection, + this.sessionReader, + workingDir, + authStateManager, + _cliPath, + ); + } + + /** + * Send message + * + * @param message - Message content + */ + async sendMessage(message: string): Promise { + await this.connection.sendPrompt(message); + } + + /** + * Set approval mode from UI + */ + async setApprovalModeFromUi( + mode: ApprovalModeValue, + ): Promise { + const modeId = mode; + try { + const res = await this.connection.setMode(modeId); + // Optimistically notify UI using response + const result = (res?.result || {}) as { modeId?: string }; + const confirmed = + (result.modeId as + | 'plan' + | 'default' + | 'auto-edit' + | 'yolo' + | undefined) || modeId; + this.callbacks.onModeChanged?.(confirmed); + return confirmed; + } catch (err) { + console.error('[QwenAgentManager] Failed to set mode:', err); + throw err; + } + } + + /** + * Validate if current session is still active + * This is a lightweight check to verify session validity + * + * @returns True if session is valid, false otherwise + */ + async validateCurrentSession(): Promise { + try { + // If we don't have a current session, it's definitely not valid + if (!this.connection.currentSessionId) { + return false; + } + + // Try to get session list to verify our session still exists + const sessions = await this.getSessionList(); + const currentSessionId = this.connection.currentSessionId; + + // Check if our current session exists in the session list + const sessionExists = sessions.some( + (session: Record) => + session.id === currentSessionId || + session.sessionId === currentSessionId, + ); + + return sessionExists; + } catch (error) { + console.warn('[QwenAgentManager] Session validation failed:', error); + // If we can't validate, assume session is invalid + return false; + } + } + + /** + * Get session list with version-aware strategy + * First tries ACP method if CLI version supports it, falls back to file system method + * + * @returns Session list + */ + async getSessionList(): Promise>> { + console.log( + '[QwenAgentManager] Getting session list with version-aware strategy', + ); + + // Check if CLI supports session/list method + const cliContextManager = CliContextManager.getInstance(); + const supportsSessionList = cliContextManager.supportsSessionList(); + + console.log( + '[QwenAgentManager] CLI supports session/list:', + supportsSessionList, + ); + + // 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); + + // 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:', + 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 + try { + console.log('[QwenAgentManager] Getting session list from file system'); + const sessions = await this.sessionReader.getAllSessions(undefined, true); + console.log( + '[QwenAgentManager] Session list from file system (all projects):', + sessions.length, + ); + + const result = sessions.map( + (session: QwenSession): Record => ({ + id: session.sessionId, + sessionId: session.sessionId, + title: this.sessionReader.getSessionTitle(session), + name: this.sessionReader.getSessionTitle(session), + startTime: session.startTime, + lastUpdated: session.lastUpdated, + messageCount: session.messages.length, + projectHash: session.projectHash, + }), + ); + + console.log( + '[QwenAgentManager] Sessions retrieved from file system:', + result.length, + ); + return result; + } catch (error) { + console.error( + '[QwenAgentManager] Failed to get session list from file system:', + error, + ); + return []; + } + } + + /** + * Get session list (paged) + * Uses ACP session/list with cursor-based pagination when available. + * Falls back to file system scan with equivalent pagination semantics. + */ + async getSessionListPaged(params?: { + cursor?: number; + size?: number; + }): Promise<{ + sessions: Array>; + nextCursor?: number; + hasMore: boolean; + }> { + const size = params?.size ?? 20; + const cursor = params?.cursor; + + const cliContextManager = CliContextManager.getInstance(); + const supportsSessionList = cliContextManager.supportsSessionList(); + + 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 + } + } + + // Fallback: file system for current project only (to match ACP semantics) + try { + const all = await this.sessionReader.getAllSessions( + this.currentWorkingDir, + false, + ); + // Sorted by lastUpdated desc already per reader + const allWithMtime = all.map((s) => ({ + raw: s, + mtime: new Date(s.lastUpdated).getTime(), + })); + const filtered = + cursor !== undefined + ? allWithMtime.filter((x) => x.mtime < cursor) + : allWithMtime; + const page = filtered.slice(0, size); + const sessions = page.map((x) => ({ + id: x.raw.sessionId, + sessionId: x.raw.sessionId, + title: this.sessionReader.getSessionTitle(x.raw), + name: this.sessionReader.getSessionTitle(x.raw), + startTime: x.raw.startTime, + lastUpdated: x.raw.lastUpdated, + messageCount: x.raw.messages.length, + projectHash: x.raw.projectHash, + })); + const nextCursorVal = + page.length > 0 ? page[page.length - 1].mtime : undefined; + const hasMore = filtered.length > size; + return { sessions, nextCursor: nextCursorVal, hasMore }; + } catch (error) { + console.error('[QwenAgentManager] File system paged list failed:', error); + return { sessions: [], hasMore: false }; + } + } + + /** + * Get session messages (read from disk) + * + * @param sessionId - Session ID + * @returns Message list + */ + 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); + } + } + + // Fallback: legacy JSON session files + const session = await this.sessionReader.getSession( + sessionId, + this.currentWorkingDir, + ); + if (!session) { + return []; + } + return session.messages.map( + (msg: { type: string; content: string; timestamp: string }) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content, + timestamp: new Date(msg.timestamp).getTime(), + }), + ); + } catch (error) { + console.error( + '[QwenAgentManager] Failed to get session messages:', + error, + ); + return []; + } + } + + // Read CLI JSONL session file and convert to ChatMessage[] for UI + private async readJsonlMessages(filePath: string): Promise { + const fs = await import('fs'); + const readline = await import('readline'); + try { + if (!fs.existsSync(filePath)) { + return []; + } + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + const records: unknown[] = []; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const obj = JSON.parse(trimmed); + records.push(obj); + } catch { + /* ignore */ + } + } + // Simple linear reconstruction: filter user/assistant and sort by timestamp + console.log( + '[QwenAgentManager] JSONL records read:', + records.length, + filePath, + ); + + // Include all types of records, not just user/assistant + // Narrow unknown JSONL rows into a minimal shape we can work with. + type JsonlRecord = { + type: string; + timestamp: string; + message?: unknown; + toolCallResult?: { callId?: string; status?: string } | unknown; + subtype?: string; + systemPayload?: { uiEvent?: Record } | unknown; + plan?: { entries?: Array> } | unknown; + }; + + const isJsonlRecord = (x: unknown): x is JsonlRecord => + typeof x === 'object' && + x !== null && + typeof (x as Record).type === 'string' && + typeof (x as Record).timestamp === 'string'; + + const allRecords = records + .filter(isJsonlRecord) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const msgs: ChatMessage[] = []; + for (const r of allRecords) { + // Handle user and assistant messages + if ((r.type === 'user' || r.type === 'assistant') && r.message) { + msgs.push({ + role: + r.type === 'user' ? ('user' as const) : ('assistant' as const), + content: this.contentToText(r.message), + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle tool call records that might have content we want to show + else if (r.type === 'tool_call' || r.type === 'tool_call_update') { + // Convert tool calls to messages if they have relevant content + const toolContent = this.extractToolCallContent(r as unknown); + if (toolContent) { + msgs.push({ + role: 'assistant', + content: toolContent, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle tool result records + else if ( + r.type === 'tool_result' && + r.toolCallResult && + typeof r.toolCallResult === 'object' + ) { + const toolResult = r.toolCallResult as { + callId?: string; + status?: string; + }; + const callId = toolResult.callId ?? 'unknown'; + const status = toolResult.status ?? 'unknown'; + const resultText = `Tool Result (${callId}): ${status}`; + msgs.push({ + role: 'assistant', + content: resultText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle system telemetry records + else if ( + r.type === 'system' && + r.subtype === 'ui_telemetry' && + r.systemPayload && + typeof r.systemPayload === 'object' && + 'uiEvent' in r.systemPayload && + (r.systemPayload as { uiEvent?: Record }).uiEvent + ) { + const uiEvent = ( + r.systemPayload as { + uiEvent?: Record; + } + ).uiEvent as Record; + let telemetryText = ''; + + if ( + typeof uiEvent['event.name'] === 'string' && + (uiEvent['event.name'] as string).includes('tool_call') + ) { + const functionName = + (uiEvent['function_name'] as string | undefined) || + 'Unknown tool'; + const status = + (uiEvent['status'] as string | undefined) || 'unknown'; + const duration = + typeof uiEvent['duration_ms'] === 'number' + ? ` (${uiEvent['duration_ms']}ms)` + : ''; + telemetryText = `Tool Call: ${functionName} - ${status}${duration}`; + } else if ( + typeof uiEvent['event.name'] === 'string' && + (uiEvent['event.name'] as string).includes('api_response') + ) { + const statusCode = + (uiEvent['status_code'] as string | number | undefined) || + 'unknown'; + const duration = + typeof uiEvent['duration_ms'] === 'number' + ? ` (${uiEvent['duration_ms']}ms)` + : ''; + telemetryText = `API Response: Status ${statusCode}${duration}`; + } else { + // Generic system telemetry + const eventName = + (uiEvent['event.name'] as string | undefined) || 'Unknown event'; + telemetryText = `System Event: ${eventName}`; + } + + if (telemetryText) { + msgs.push({ + role: 'assistant', + content: telemetryText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle plan entries + else if ( + r.type === 'plan' && + r.plan && + typeof r.plan === 'object' && + 'entries' in r.plan + ) { + const planEntries = + ((r.plan as { entries?: Array> }) + .entries as Array> | undefined) || []; + if (planEntries.length > 0) { + const planText = planEntries + .map( + (entry: Record, index: number) => + `${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`, + ) + .join('\n'); + msgs.push({ + role: 'assistant', + content: `Plan:\n${planText}`, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle other types if needed + } + + console.log( + '[QwenAgentManager] JSONL messages reconstructed:', + msgs.length, + ); + return msgs; + } catch (err) { + console.warn('[QwenAgentManager] Failed to read JSONL messages:', err); + return []; + } + } + + // Extract meaningful content from tool call records + private extractToolCallContent(record: unknown): string | null { + try { + // Type guard for record + if (typeof record !== 'object' || record === null) { + return null; + } + + // Cast to a more specific type for easier handling + const typedRecord = record as Record; + + // If the tool call has a result or output, include it + if ('toolCallResult' in typedRecord && typedRecord.toolCallResult) { + return `Tool result: ${this.formatValue(typedRecord.toolCallResult)}`; + } + + // If the tool call has content, include it + if ('content' in typedRecord && typedRecord.content) { + return this.formatValue(typedRecord.content); + } + + // If the tool call has a title or name, include it + if ( + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) + ) { + return `Tool: ${typedRecord.title || typedRecord.name}`; + } + + // Handle tool_call records with more details + if ( + typedRecord.type === 'tool_call' && + 'toolCall' in typedRecord && + typedRecord.toolCall + ) { + const toolCall = typedRecord.toolCall as Record; + if ( + ('title' in toolCall && toolCall.title) || + ('name' in toolCall && toolCall.name) + ) { + return `Tool call: ${toolCall.title || toolCall.name}`; + } + if ('rawInput' in toolCall && toolCall.rawInput) { + return `Tool input: ${this.formatValue(toolCall.rawInput)}`; + } + } + + // Handle tool_call_update records with status + if (typedRecord.type === 'tool_call_update') { + const status = + ('status' in typedRecord && typedRecord.status) || 'unknown'; + const title = + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) || + 'Unknown tool'; + return `Tool ${status}: ${title}`; + } + + return null; + } catch { + return null; + } + } + + // Format any value to a string for display + private formatValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); + } + + // Extract plain text from Content (genai Content) + private contentToText(message: unknown): string { + try { + // Type guard for message + if (typeof message !== 'object' || message === null) { + return ''; + } + + // Cast to a more specific type for easier handling + const typedMessage = message as Record; + + const parts = Array.isArray(typedMessage.parts) ? typedMessage.parts : []; + const texts: string[] = []; + for (const p of parts) { + // Type guard for part + if (typeof p !== 'object' || p === null) { + continue; + } + + const typedPart = p as Record; + if (typeof typedPart.text === 'string') { + texts.push(typedPart.text); + } else if (typeof typedPart.data === 'string') { + texts.push(typedPart.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + + /** + * Save session via /chat save command + * Since CLI doesn't support session/save ACP method, we send /chat save command directly + * + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response + */ + async saveSessionViaCommand( + sessionId: string, + tag: string, + ): Promise<{ success: boolean; message?: string }> { + try { + console.log( + '[QwenAgentManager] Saving session via /chat save command:', + sessionId, + 'with tag:', + tag, + ); + + // Send /chat save command as a prompt + // The CLI will handle this as a special command + await this.connection.sendPrompt(`/chat save "${tag}"`); + + console.log('[QwenAgentManager] /chat save command sent successfully'); + return { + success: true, + message: `Session saved with tag: ${tag}`, + }; + } catch (error) { + console.error('[QwenAgentManager] /chat save command failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Save session via ACP session/save method (deprecated, CLI doesn't support) + * + * @deprecated Use saveSessionViaCommand instead + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response + */ + async saveSessionViaAcp( + sessionId: string, + tag: string, + ): Promise<{ success: boolean; message?: string }> { + // Fallback to command-based save since CLI doesn't support session/save ACP method + console.warn( + '[QwenAgentManager] saveSessionViaAcp is deprecated, using command-based save instead', + ); + 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 + * + * @param sessionId - Session ID + * @returns Load response or error + */ + async loadSessionViaAcp( + 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; + console.log( + '[QwenAgentManager] Rehydration start for session:', + sessionId, + ); + console.log( + '[QwenAgentManager] Attempting session/load via ACP for session:', + sessionId, + ); + const response = await this.connection.loadSession( + sessionId, + cwdOverride, + ); + console.log( + '[QwenAgentManager] Session load succeeded. Response:', + JSON.stringify(response).substring(0, 200), + ); + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + '[QwenAgentManager] Session load via ACP failed for session:', + sessionId, + ); + console.error('[QwenAgentManager] Error type:', error?.constructor?.name); + console.error('[QwenAgentManager] Error message:', errorMessage); + + // Check if error is from ACP response + if (error && typeof error === 'object') { + // Safely check if 'error' property exists + if ('error' in error) { + const acpError = error as { + error?: { code?: number; message?: string }; + }; + if (acpError.error) { + console.error( + '[QwenAgentManager] ACP error code:', + acpError.error.code, + ); + console.error( + '[QwenAgentManager] ACP error message:', + acpError.error.message, + ); + } + } else { + console.error('[QwenAgentManager] Non-ACPIf error details:', error); + } + } + + throw error; + } finally { + // End rehydration routing regardless of outcome + console.log('[QwenAgentManager] Rehydration end for session:', sessionId); + this.rehydratingSessionId = null; + } + } + + /** + * Load session with version-aware strategy + * First tries ACP method if CLI version supports it, falls back to file system method + * + * @param sessionId - Session ID to load + * @returns Loaded session messages or null + */ + async loadSession(sessionId: string): Promise { + console.log( + '[QwenAgentManager] Loading session with version-aware strategy:', + sessionId, + ); + + // Check if CLI supports session/load method + const cliContextManager = CliContextManager.getInstance(); + const supportsSessionLoad = cliContextManager.supportsSessionLoad(); + + 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, + ); + } + } + + // Always fall back to file system method + try { + console.log( + '[QwenAgentManager] Loading session messages from file system', + ); + const messages = await this.loadSessionMessagesFromFile(sessionId); + console.log( + '[QwenAgentManager] Session messages loaded successfully from file system', + ); + return messages; + } catch (error) { + console.error( + '[QwenAgentManager] Failed to load session messages from file system:', + error, + ); + return null; + } + } + + /** + * Load session messages from file system + * + * @param sessionId - Session ID to load + * @returns Loaded session messages + */ + private async loadSessionMessagesFromFile( + sessionId: string, + ): Promise { + try { + console.log( + '[QwenAgentManager] Loading session from file system:', + sessionId, + ); + + // Load session from file system + const session = await this.sessionManager.loadSession( + sessionId, + this.currentWorkingDir, + ); + + if (!session) { + console.log( + '[QwenAgentManager] Session not found in file system:', + sessionId, + ); + return null; + } + + // Convert message format + const messages: ChatMessage[] = session.messages.map((msg) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content, + timestamp: new Date(msg.timestamp).getTime(), + })); + + return messages; + } catch (error) { + console.error( + '[QwenAgentManager] Session load from file system failed:', + error, + ); + throw error; + } + } + + /** + * 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 + * + * Note: Authentication should be done in connect() method, only create session here + * + * @param workingDir - Working directory + * @returns Newly created session ID + */ + async createNewSession( + workingDir: string, + authStateManager?: AuthStateManager, + ): Promise { + 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...', + ); + 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 { + 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(); + } + throw reauthErr; + } + } else { + throw err; + } + } + const newSessionId = this.connection.currentSessionId; + console.log( + '[QwenAgentManager] New session created with ID:', + newSessionId, + ); + return newSessionId; + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + */ + async switchToSession(sessionId: string): Promise { + await this.connection.switchSession(sessionId); + } + + /** + * Cancel current prompt + */ + async cancelCurrentPrompt(): Promise { + console.log('[QwenAgentManager] Cancelling current prompt'); + await this.connection.cancelSession(); + } + + /** + * Register message callback + * + * @param callback - Message callback function + */ + onMessage(callback: (message: ChatMessage) => void): void { + this.callbacks.onMessage = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register stream chunk callback + * + * @param callback - Stream chunk callback function + */ + onStreamChunk(callback: (chunk: string) => void): void { + this.callbacks.onStreamChunk = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register thought chunk callback + * + * @param callback - Thought chunk callback function + */ + onThoughtChunk(callback: (chunk: string) => void): void { + this.callbacks.onThoughtChunk = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register tool call callback + * + * @param callback - Tool call callback function + */ + onToolCall(callback: (update: ToolCallUpdateData) => void): void { + this.callbacks.onToolCall = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register plan callback + * + * @param callback - Plan callback function + */ + onPlan(callback: (entries: PlanEntry[]) => void): void { + this.callbacks.onPlan = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register permission request callback + * + * @param callback - Permission request callback function + */ + onPermissionRequest( + callback: (request: AcpPermissionRequest) => Promise, + ): void { + this.callbacks.onPermissionRequest = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register end-of-turn callback + * + * @param callback - Called when ACP stopReason === 'end_turn' + */ + onEndTurn(callback: () => void): void { + this.callbacks.onEndTurn = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register initialize mode info callback + */ + onModeInfo( + callback: (info: { + currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; + availableModes?: Array<{ + id: 'plan' | 'default' | 'auto-edit' | 'yolo'; + name: string; + description: string; + }>; + }) => void, + ): void { + this.callbacks.onModeInfo = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register mode changed callback + */ + onModeChanged( + callback: (modeId: 'plan' | 'default' | 'auto-edit' | 'yolo') => void, + ): void { + this.callbacks.onModeChanged = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Disconnect + */ + disconnect(): void { + this.connection.disconnect(); + } + + /** + * Check if connected + */ + get isConnected(): boolean { + return this.connection.isConnected; + } + + /** + * Get current session ID + */ + get currentSessionId(): string | null { + return this.connection.currentSessionId; + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts new file mode 100644 index 00000000..11e7199a --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Qwen Connection Handler + * + * 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 { authMethod } from '../types/acpTypes.js'; + +/** + * Qwen Connection Handler class + * Handles connection, authentication, and session initialization + */ +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 { + 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'); + + // 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'); + } + + // Try to restore existing session or create new session + // Note: Auto-restore on connect is disabled to avoid surprising loads + // when user opens a "New Chat" tab. Restoration is now an explicit action + // (session selector → session/load) or handled by higher-level flows. + const sessionRestored = false; + + // Create new session if unable to restore + if (!sessionRestored) { + console.log( + '[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...', + ); + await this.newSessionWithRetry( + connection, + workingDir, + 3, + authMethod, + authStateManager, + ); + 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); + } + } 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(); + } + + throw sessionError; + } + } + + console.log(`\n========================================`); + console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); + console.log(`========================================\n`); + } + + /** + * Create new session (with retry) + * + * @param connection - ACP connection instance + * @param workingDir - Working directory + * @param maxRetries - Maximum number of retries + */ + private async newSessionWithRetry( + connection: AcpConnection, + workingDir: string, + maxRetries: number, + authMethod: string, + authStateManager?: AuthStateManager, + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log( + `[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`, + ); + await connection.newSession(workingDir); + console.log('[QwenAgentManager] Session created successfully'); + return; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[QwenAgentManager] Session creation attempt ${attempt} failed:`, + errorMessage, + ); + + // 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)'); + if (requiresAuth) { + console.log( + '[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...', + ); + try { + await connection.authenticate(authMethod); + if (authStateManager) { + await authStateManager.saveAuthState(workingDir, authMethod); + } + // Retry immediately after successful auth + await connection.newSession(workingDir); + console.log( + '[QwenAgentManager] Session created successfully after auth', + ); + return; + } catch (authErr) { + console.error( + '[QwenAgentManager] Re-authentication failed:', + authErr, + ); + if (authStateManager) { + await authStateManager.clearAuthState(); + } + // Fall through to retry logic below + } + } + + if (attempt === maxRetries) { + throw new Error( + `Session creation failed after ${maxRetries} attempts: ${errorMessage}`, + ); + } + + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); + console.log(`[QwenAgentManager] Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts new file mode 100644 index 00000000..2bd609bb --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -0,0 +1,336 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; + +/** + * Qwen Session Manager + * + * This service provides direct filesystem access to save and load sessions + * without relying on the CLI's ACP session/save method. + * + * Note: This is primarily used as a fallback mechanism when ACP methods are + * unavailable or fail. In normal operation, ACP session/list and session/load + * should be preferred for consistency with the CLI. + */ +export class QwenSessionManager { + private qwenDir: string; + + constructor() { + this.qwenDir = path.join(os.homedir(), '.qwen'); + } + + /** + * Calculate project hash (same as CLI) + * Qwen CLI uses SHA256 hash of the project path + */ + private getProjectHash(workingDir: string): string { + return crypto.createHash('sha256').update(workingDir).digest('hex'); + } + + /** + * Get the session directory for a project + */ + private getSessionDir(workingDir: string): string { + const projectHash = this.getProjectHash(workingDir); + return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + } + + /** + * Generate a new session ID + */ + private generateSessionId(): string { + return crypto.randomUUID(); + } + + /** + * 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) + * + * @param messages - Current conversation messages + * @param sessionName - Name/tag for the saved session + * @param workingDir - Current working directory + * @returns Session ID of the saved session + */ + async saveSession( + messages: QwenMessage[], + sessionName: string, + workingDir: string, + ): Promise { + try { + // Create session directory if it doesn't exist + const sessionDir = this.getSessionDir(workingDir); + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + } + + // Generate session ID and filename using CLI's naming convention + const sessionId = this.generateSessionId(); + const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars) + const now = new Date(); + const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD + const isoTime = now + .toISOString() + .split('T')[1] + .split(':') + .slice(0, 2) + .join('-'); // HH-MM + const filename = `session-${isoDate}T${isoTime}-${shortId}.json`; + const filePath = path.join(sessionDir, filename); + + // Create session object + const session: QwenSession = { + sessionId, + projectHash: this.getProjectHash(workingDir), + startTime: messages[0]?.timestamp || new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages, + }; + + // Save session to file + fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8'); + + console.log(`[QwenSessionManager] Session saved: ${filePath}`); + return sessionId; + } catch (error) { + console.error('[QwenSessionManager] Failed to save session:', error); + throw error; + } + } + + /** + * Load a saved session by name + * + * @param sessionName - Name/tag of the session to load + * @param workingDir - Current working directory + * @returns Loaded session or null if not found + */ + async loadSession( + sessionId: string, + workingDir: string, + ): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + const filename = `session-${sessionId}.json`; + const filePath = path.join(sessionDir, filename); + + if (!fs.existsSync(filePath)) { + console.log(`[QwenSessionManager] Session file not found: ${filePath}`); + return null; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + + console.log(`[QwenSessionManager] Session loaded: ${filePath}`); + return session; + } catch (error) { + console.error('[QwenSessionManager] Failed to load session:', error); + return null; + } + } + + /** + * List all saved sessions + * + * @param workingDir - Current working directory + * @returns Array of session objects + */ + async listSessions(workingDir: string): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + + if (!fs.existsSync(sessionDir)) { + return []; + } + + const files = fs + .readdirSync(sessionDir) + .filter( + (file) => file.startsWith('session-') && file.endsWith('.json'), + ); + + const sessions: QwenSession[] = []; + for (const file of files) { + try { + const filePath = path.join(sessionDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + sessions.push(session); + } catch (error) { + console.error( + `[QwenSessionManager] Failed to read session file ${file}:`, + error, + ); + } + } + + // Sort by last updated time (newest first) + sessions.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + return sessions; + } catch (error) { + console.error('[QwenSessionManager] Failed to list sessions:', error); + return []; + } + } + + /** + * Delete a saved session + * + * @param sessionId - ID of the session to delete + * @param workingDir - Current working directory + * @returns True if deleted successfully, false otherwise + */ + async deleteSession(sessionId: string, workingDir: string): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + const filename = `session-${sessionId}.json`; + const filePath = path.join(sessionDir, filename); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[QwenSessionManager] Session deleted: ${filePath}`); + return true; + } + + return false; + } catch (error) { + console.error('[QwenSessionManager] Failed to delete session:', error); + return false; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts new file mode 100644 index 00000000..6e2d065d --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface QwenMessage { + id: string; + timestamp: string; + type: 'user' | 'qwen'; + content: string; + thoughts?: unknown[]; + tokens?: { + input: number; + output: number; + cached: number; + thoughts: number; + tool: number; + total: number; + }; + model?: string; +} + +export interface QwenSession { + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + messages: QwenMessage[]; + filePath?: string; +} + +export class QwenSessionReader { + private qwenDir: string; + + constructor() { + this.qwenDir = path.join(os.homedir(), '.qwen'); + } + + /** + * Get all session list (optional: current project only or all projects) + */ + async getAllSessions( + workingDir?: string, + allProjects: boolean = false, + ): Promise { + try { + const sessions: QwenSession[] = []; + + if (!allProjects && workingDir) { + // Current project only + const projectHash = await this.getProjectHash(workingDir); + const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + const projectSessions = await this.readSessionsFromDir(chatsDir); + sessions.push(...projectSessions); + } else { + // All projects + const tmpDir = path.join(this.qwenDir, 'tmp'); + if (!fs.existsSync(tmpDir)) { + console.log('[QwenSessionReader] Tmp directory not found:', tmpDir); + return []; + } + + const projectDirs = fs.readdirSync(tmpDir); + for (const projectHash of projectDirs) { + const chatsDir = path.join(tmpDir, projectHash, 'chats'); + const projectSessions = await this.readSessionsFromDir(chatsDir); + sessions.push(...projectSessions); + } + } + + // Sort by last updated time + sessions.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + return sessions; + } catch (error) { + console.error('[QwenSessionReader] Failed to get sessions:', error); + return []; + } + } + + /** + * Read all sessions from specified directory + */ + private async readSessionsFromDir(chatsDir: string): Promise { + const sessions: QwenSession[] = []; + + if (!fs.existsSync(chatsDir)) { + return sessions; + } + + const files = fs + .readdirSync(chatsDir) + .filter((f) => f.startsWith('session-') && f.endsWith('.json')); + + for (const file of files) { + const filePath = path.join(chatsDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + session.filePath = filePath; + sessions.push(session); + } catch (error) { + console.error( + '[QwenSessionReader] Failed to read session file:', + filePath, + error, + ); + } + } + + return sessions; + } + + /** + * Get details of specific session + */ + async getSession( + sessionId: string, + _workingDir?: string, + ): Promise { + // First try to find in all projects + const sessions = await this.getAllSessions(undefined, true); + return sessions.find((s) => s.sessionId === sessionId) || null; + } + + /** + * Calculate project hash (needs to be consistent with Qwen CLI) + * 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'); + } + + /** + * Get session title (based on first user message) + */ + getSessionTitle(session: QwenSession): string { + const firstUserMessage = session.messages.find((m) => m.type === 'user'); + if (firstUserMessage) { + // Extract first 50 characters as title + return ( + firstUserMessage.content.substring(0, 50) + + (firstUserMessage.content.length > 50 ? '...' : '') + ); + } + return 'Untitled Session'; + } + + /** + * Delete session file + */ + async deleteSession( + sessionId: string, + _workingDir: string, + ): Promise { + try { + const session = await this.getSession(sessionId, _workingDir); + if (session && session.filePath) { + fs.unlinkSync(session.filePath); + return true; + } + return false; + } catch (error) { + console.error('[QwenSessionReader] Failed to delete session:', error); + return false; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts new file mode 100644 index 00000000..e27fbe67 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Qwen Session Update Handler + * + * Handles session updates from ACP and dispatches them to appropriate callbacks + */ + +import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js'; +import type { QwenAgentCallbacks } from '../types/chatTypes.js'; + +/** + * Qwen Session Update Handler class + * Processes various session update events and calls appropriate callbacks + */ +export class QwenSessionUpdateHandler { + private callbacks: QwenAgentCallbacks; + + constructor(callbacks: QwenAgentCallbacks) { + this.callbacks = callbacks; + } + + /** + * Update callbacks + * + * @param callbacks - New callback collection + */ + updateCallbacks(callbacks: QwenAgentCallbacks): void { + this.callbacks = callbacks; + } + + /** + * Handle session update + * + * @param data - ACP session update data + */ + handleSessionUpdate(data: AcpSessionUpdate): void { + const update = data.update; + console.log( + '[SessionUpdateHandler] Processing update type:', + update.sessionUpdate, + ); + + switch (update.sessionUpdate) { + case 'user_message_chunk': + if (update.content?.text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(update.content.text); + } + break; + + case 'agent_message_chunk': + if (update.content?.text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(update.content.text); + } + break; + + case 'agent_thought_chunk': + if (update.content?.text) { + if (this.callbacks.onThoughtChunk) { + this.callbacks.onThoughtChunk(update.content.text); + } else if (this.callbacks.onStreamChunk) { + // Fallback to regular stream processing + console.log( + '[SessionUpdateHandler] 🧠 Falling back to onStreamChunk', + ); + this.callbacks.onStreamChunk(update.content.text); + } + } + break; + + case 'tool_call': { + // Handle new tool call + if (this.callbacks.onToolCall && 'toolCallId' in update) { + this.callbacks.onToolCall({ + toolCallId: update.toolCallId as string, + kind: (update.kind as string) || undefined, + title: (update.title as string) || undefined, + status: (update.status as string) || undefined, + rawInput: update.rawInput, + content: update.content as + | Array> + | undefined, + locations: update.locations as + | Array<{ path: string; line?: number | null }> + | undefined, + }); + } + break; + } + + case 'tool_call_update': { + if (this.callbacks.onToolCall && 'toolCallId' in update) { + this.callbacks.onToolCall({ + toolCallId: update.toolCallId as string, + kind: (update.kind as string) || undefined, + title: (update.title as string) || undefined, + status: (update.status as string) || undefined, + rawInput: update.rawInput, + content: update.content as + | Array> + | undefined, + locations: update.locations as + | Array<{ path: string; line?: number | null }> + | undefined, + }); + } + break; + } + + case 'plan': { + if ('entries' in update) { + const entries = update.entries as Array<{ + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; + }>; + + if (this.callbacks.onPlan) { + this.callbacks.onPlan(entries); + } else if (this.callbacks.onStreamChunk) { + // Fallback to stream processing + const planText = + '\n📋 Plan:\n' + + entries + .map( + (entry, i) => + `${i + 1}. [${entry.priority}] ${entry.content}`, + ) + .join('\n'); + this.callbacks.onStreamChunk(planText); + } + } + break; + } + + case 'current_mode_update': { + // Notify UI about mode change + try { + const modeId = (update as unknown as { modeId?: ApprovalModeValue }) + .modeId; + if (modeId && this.callbacks.onModeChanged) { + this.callbacks.onModeChanged(modeId); + } + } catch (err) { + console.warn( + '[SessionUpdateHandler] Failed to handle mode update', + err, + ); + } + break; + } + + default: + console.log('[QwenAgentManager] Unhandled session update type'); + break; + } + } +} diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts new file mode 100644 index 00000000..1fb4de17 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const JSONRPC_VERSION = '2.0' as const; +export const authMethod = 'qwen-oauth'; + +export interface AcpRequest { + jsonrpc: typeof JSONRPC_VERSION; + id: number; + method: string; + params?: unknown; +} + +export interface AcpResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: number; + result?: unknown; + capabilities?: { + [key: string]: unknown; + }; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +export interface AcpNotification { + jsonrpc: typeof JSONRPC_VERSION; + method: string; + params?: unknown; +} + +export interface BaseSessionUpdate { + sessionId: string; +} + +// Content block type (simplified version, use schema.ContentBlock for validation) +export interface ContentBlock { + type: 'text' | 'image'; + text?: string; + data?: string; + mimeType?: string; + uri?: string; +} + +export interface UserMessageChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'user_message_chunk'; + content: ContentBlock; + }; +} + +export interface AgentMessageChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'agent_message_chunk'; + content: ContentBlock; + }; +} + +export interface AgentThoughtChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'agent_thought_chunk'; + content: ContentBlock; + }; +} + +export interface ToolCallUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'tool_call'; + toolCallId: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + title: string; + kind: + | 'read' + | 'edit' + | 'execute' + | 'delete' + | 'move' + | 'search' + | 'fetch' + | 'think' + | 'other'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: 'text'; + text: string; + }; + path?: string; + oldText?: string | null; + newText?: string; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +export interface ToolCallStatusUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'tool_call_update'; + toolCallId: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + title?: string; + kind?: string; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: 'text'; + text: string; + }; + path?: string; + oldText?: string | null; + newText?: string; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +export interface PlanUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'plan'; + entries: Array<{ + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; + }>; + }; +} + +export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; + +export { + ApprovalMode, + APPROVAL_MODE_MAP, + APPROVAL_MODE_INFO, + getApprovalModeInfoFromString, +} from './approvalModeTypes.js'; + +// Cyclic next-mode mapping used by UI toggles and other consumers +export const NEXT_APPROVAL_MODE: { + [k in ApprovalModeValue]: ApprovalModeValue; +} = { + // Hide "plan" from the public toggle sequence for now + // Cycle: default -> auto-edit -> yolo -> default + default: 'auto-edit', + 'auto-edit': 'yolo', + plan: 'yolo', + yolo: 'default', +}; + +// Current mode update (sent by agent when mode changes) +export interface CurrentModeUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'current_mode_update'; + modeId: ApprovalModeValue; + }; +} + +export type AcpSessionUpdate = + | UserMessageChunkUpdate + | AgentMessageChunkUpdate + | AgentThoughtChunkUpdate + | ToolCallUpdate + | ToolCallStatusUpdate + | PlanUpdate + | CurrentModeUpdate; + +// Permission request (simplified version, use schema.RequestPermissionRequest for validation) +export interface AcpPermissionRequest { + sessionId: string; + options: Array<{ + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + }>; + toolCall: { + toolCallId: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + title?: string; + kind?: string; + }; +} + +export type AcpMessage = + | AcpRequest + | AcpNotification + | AcpResponse + | AcpSessionUpdate; diff --git a/packages/vscode-ide-companion/src/types/approvalModeTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeTypes.ts new file mode 100644 index 00000000..ac9b22e5 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeTypes.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Enum for approval modes with UI-friendly labels + * Represents the different approval modes available in the ACP protocol + * with their corresponding user-facing display names + */ +export enum ApprovalMode { + PLAN = 'plan', + DEFAULT = 'default', + AUTO_EDIT = 'auto-edit', + YOLO = 'yolo', +} + +/** + * Mapping from string values to enum values for runtime conversion + */ +export const APPROVAL_MODE_MAP: Record = { + plan: ApprovalMode.PLAN, + default: ApprovalMode.DEFAULT, + 'auto-edit': ApprovalMode.AUTO_EDIT, + yolo: ApprovalMode.YOLO, +}; + +/** + * UI display information for each approval mode + */ +export const APPROVAL_MODE_INFO: Record< + ApprovalMode, + { + label: string; + title: string; + iconType?: 'edit' | 'auto' | 'plan' | 'yolo'; + } +> = { + [ApprovalMode.PLAN]: { + label: 'Plan mode', + title: 'Qwen will plan before executing. Click to switch modes.', + iconType: 'plan', + }, + [ApprovalMode.DEFAULT]: { + label: 'Ask before edits', + title: 'Qwen will ask before each edit. Click to switch modes.', + iconType: 'edit', + }, + [ApprovalMode.AUTO_EDIT]: { + label: 'Edit automatically', + title: 'Qwen will edit files automatically. Click to switch modes.', + iconType: 'auto', + }, + [ApprovalMode.YOLO]: { + label: 'YOLO', + title: 'Automatically approve all tools. Click to switch modes.', + iconType: 'yolo', + }, +}; + +/** + * Get UI display information for an approval mode from string value + */ +export function getApprovalModeInfoFromString(mode: string): { + label: string; + title: string; + iconType?: 'edit' | 'auto' | 'plan' | 'yolo'; +} { + const enumValue = APPROVAL_MODE_MAP[mode]; + if (enumValue !== undefined) { + return APPROVAL_MODE_INFO[enumValue]; + } + return { + label: 'Unknown mode', + title: 'Unknown edit mode', + iconType: undefined, + }; +} diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts new file mode 100644 index 00000000..90ebbb87 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +export interface PlanEntry { + content: string; + priority?: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; +} + +export interface ToolCallUpdateData { + toolCallId: string; + kind?: string; + title?: string; + status?: string; + rawInput?: unknown; + content?: Array>; + locations?: Array<{ path: string; line?: number | null }>; +} + +export interface QwenAgentCallbacks { + onMessage?: (message: ChatMessage) => void; + onStreamChunk?: (chunk: string) => void; + onThoughtChunk?: (chunk: string) => void; + onToolCall?: (update: ToolCallUpdateData) => void; + onPlan?: (entries: PlanEntry[]) => void; + onPermissionRequest?: (request: AcpPermissionRequest) => Promise; + onEndTurn?: () => void; + onModeInfo?: (info: { + currentModeId?: ApprovalModeValue; + availableModes?: Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }>; + }) => void; + onModeChanged?: (modeId: ApprovalModeValue) => void; +} + +export interface ToolCallUpdate { + type: 'tool_call' | 'tool_call_update'; + toolCallId: string; + kind?: string; + title?: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + path?: string; + oldText?: string | null; + newText?: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + timestamp?: number; // Add timestamp field for message ordering +} diff --git a/packages/vscode-ide-companion/src/types/completionItemTypes.ts b/packages/vscode-ide-companion/src/types/completionItemTypes.ts new file mode 100644 index 00000000..8bc884b3 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/completionItemTypes.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +export interface CompletionItem { + id: string; + label: string; + description?: string; + icon?: React.ReactNode; + type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info'; + // Value inserted into the input when selected (e.g., filename or command) + value?: string; + // Optional full path for files (used to build @filename -> full path mapping) + path?: string; +} diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts new file mode 100644 index 00000000..b49bd027 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChildProcess } from 'child_process'; +import type { AcpSessionUpdate, AcpPermissionRequest } from './acpTypes.js'; + +export interface PendingRequest { + resolve: (value: T) => void; + reject: (error: Error) => void; + timeoutId?: NodeJS.Timeout; + method: string; +} + +export interface AcpConnectionCallbacks { + onSessionUpdate: (data: AcpSessionUpdate) => void; + onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + optionId: string; + }>; + onEndTurn: () => void; +} + +export interface AcpConnectionState { + child: ChildProcess | null; + pendingRequests: Map>; + nextRequestId: number; + sessionId: string | null; + isInitialized: boolean; +} diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts new file mode 100644 index 00000000..3bfc675f --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { openChatCommand } from '../commands/index.js'; + +/** + * Find the editor group immediately to the left of the Qwen chat webview. + * - If the chat webview group is the leftmost group, returns undefined. + * - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'. + */ +export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined { + try { + const groups = vscode.window.tabGroups.all; + + // Locate the group that contains our chat webview + const webviewGroup = groups.find((group) => + group.tabs.some((tab) => { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + return ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ); + }), + ); + + if (!webviewGroup) { + return undefined; + } + + // Among all groups to the left (smaller viewColumn), choose the one with + // the largest viewColumn value (i.e. the immediate neighbor on the left). + let candidate: + | { group: vscode.TabGroup; viewColumn: vscode.ViewColumn } + | undefined; + for (const g of groups) { + if (g.viewColumn < webviewGroup.viewColumn) { + if (!candidate || g.viewColumn > candidate.viewColumn) { + candidate = { group: g, viewColumn: g.viewColumn }; + } + } + } + + return candidate?.viewColumn; + } catch (_err) { + // Best-effort only; fall back to default behavior if anything goes wrong + return undefined; + } +} + +/** + * Ensure there is an editor group directly to the left of the Qwen chat webview. + * - If one exists, return its ViewColumn. + * - If none exists, focus the chat panel and create a new group on its left, + * then return the new group's ViewColumn (which equals the chat's previous column). + * - If the chat webview cannot be located, returns undefined. + */ +export async function ensureLeftGroupOfChatWebview(): Promise< + vscode.ViewColumn | undefined +> { + // First try to find an existing left neighbor + const existing = findLeftGroupOfChatWebview(); + if (existing !== undefined) { + return existing; + } + + // Locate the chat webview group + const groups = vscode.window.tabGroups.all; + const webviewGroup = groups.find((group) => + group.tabs.some((tab) => { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + return ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ); + }), + ); + + if (!webviewGroup) { + return undefined; + } + + const previousChatColumn = webviewGroup.viewColumn; + + // Make the chat group active by revealing the panel + try { + await vscode.commands.executeCommand(openChatCommand); + } catch { + // Best-effort; continue even if this fails + } + + // Create a new group to the left of the chat group + try { + await vscode.commands.executeCommand('workbench.action.newGroupLeft'); + } catch { + // If we fail to create a group, fall back to default behavior + return undefined; + } + + // Restore focus to chat (optional), so we don't disturb user focus + try { + await vscode.commands.executeCommand(openChatCommand); + } catch { + // Ignore + } + + // The new left group's column equals the chat's previous column + return previousChatColumn; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx new file mode 100644 index 00000000..4b51d6b6 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -0,0 +1,749 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo, + useLayoutEffect, +} from 'react'; +import { useVSCode } from './hooks/useVSCode.js'; +import { useSessionManagement } from './hooks/session/useSessionManagement.js'; +import { useFileContext } from './hooks/file/useFileContext.js'; +import { useMessageHandling } from './hooks/message/useMessageHandling.js'; +import { useToolCalls } from './hooks/useToolCalls.js'; +import { useWebViewMessages } from './hooks/useWebViewMessages.js'; +import { useMessageSubmit } from './hooks/useMessageSubmit.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, +} from './components/PermissionDrawer/PermissionRequest.js'; +import type { TextMessage } from './hooks/message/useMessageHandling.js'; +import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js'; +import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js'; +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 { type CompletionItem } from '../types/completionItemTypes.js'; +import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; +import { ChatHeader } from './components/layout/ChatHeader.js'; +import { + UserMessage, + AssistantMessage, + ThinkingMessage, + WaitingMessage, + InterruptedMessage, +} from './components/messages/index.js'; +import { InputForm } from './components/layout/InputForm.js'; +import { SessionSelector } from './components/layout/SessionSelector.js'; +import { FileIcon, UserIcon } from './components/icons/index.js'; +import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/acpTypes.js'; +import type { PlanEntry } from '../types/chatTypes.js'; + +export const App: React.FC = () => { + const vscode = useVSCode(); + + // Core hooks + const sessionManagement = useSessionManagement(vscode); + const fileContext = useFileContext(vscode); + const messageHandling = useMessageHandling(); + const { + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + } = useToolCalls(); + + // UI state + const [inputText, setInputText] = useState(''); + const [permissionRequest, setPermissionRequest] = useState<{ + options: PermissionOption[]; + toolCall: PermissionToolCall; + } | null>(null); + const [planEntries, setPlanEntries] = useState([]); + const messagesEndRef = useRef( + null, + ) as React.RefObject; + // Scroll container for message list; used to keep the view anchored to the latest content + const messagesContainerRef = useRef( + null, + ) as React.RefObject; + const inputFieldRef = useRef( + null, + ) as React.RefObject; + + const [editMode, setEditMode] = useState( + ApprovalMode.DEFAULT, + ); + const [thinkingEnabled, setThinkingEnabled] = useState(false); + const [isComposing, setIsComposing] = useState(false); + // When true, do NOT auto-attach the active editor file/selection to message context + const [skipAutoActiveContext, setSkipAutoActiveContext] = useState(false); + + // Completion system + const getCompletionItems = React.useCallback( + async (trigger: '@' | '/', query: string): Promise => { + if (trigger === '@') { + if (!fileContext.hasRequestedFiles) { + fileContext.requestWorkspaceFiles(); + } + + const fileIcon = ; + const allItems: CompletionItem[] = fileContext.workspaceFiles.map( + (file) => ({ + id: file.id, + label: file.label, + description: file.description, + type: 'file' as const, + icon: fileIcon, + // Insert filename after @, keep path for mapping + value: file.label, + path: file.path, + }), + ); + + if (query && query.length >= 1) { + fileContext.requestWorkspaceFiles(query); + const lowerQuery = query.toLowerCase(); + return allItems.filter( + (item) => + item.label.toLowerCase().includes(lowerQuery) || + (item.description && + item.description.toLowerCase().includes(lowerQuery)), + ); + } + + // If first time and still loading, show a placeholder + if (allItems.length === 0) { + return [ + { + id: 'loading-files', + label: 'Searching files…', + description: 'Type to filter, or wait a moment…', + type: 'info' as const, + }, + ]; + } + + return allItems; + } else { + // Handle slash commands + const commands: CompletionItem[] = [ + { + id: 'login', + label: '/login', + description: 'Login to Qwen Code', + type: 'command', + icon: , + }, + ]; + + return commands.filter((cmd) => + cmd.label.toLowerCase().includes(query.toLowerCase()), + ); + } + }, + [fileContext], + ); + + const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + + // 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 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]); + + // Message submission + const handleSubmit = useMessageSubmit({ + inputText, + setInputText, + messageHandling, + fileContext, + skipAutoActiveContext, + vscode, + inputFieldRef, + isStreaming: messageHandling.isStreaming, + }); + + // Handle cancel/stop from the input bar + // Emit a cancel to the extension and immediately reflect interruption locally. + const handleCancel = useCallback(() => { + if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) { + // Proactively end local states and add an 'Interrupted' line + try { + messageHandling.endStreaming?.(); + } catch { + /* no-op */ + } + try { + messageHandling.clearWaitingForResponse?.(); + } catch { + /* no-op */ + } + messageHandling.addMessage({ + role: 'assistant', + content: 'Interrupted', + timestamp: Date.now(), + }); + } + // Notify extension/agent to cancel server-side work + vscode.postMessage({ + type: 'cancelStreaming', + data: {}, + }); + }, [messageHandling, vscode]); + + // Message handling + useWebViewMessages({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest: setPermissionRequest, + inputFieldRef, + setInputText, + setEditMode, + }); + + // Auto-scroll handling: keep the view pinned to bottom when new content arrives, + // but don't interrupt the user if they scrolled up. + // We track whether the user is currently "pinned" to the bottom (near the end). + const [pinnedToBottom, setPinnedToBottom] = useState(true); + const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 }); + + // Observe scroll position to know if user has scrolled away from the bottom. + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) { + return; + } + + const onScroll = () => { + // Use a small threshold so slight deltas don't flip the state. + // Note: there's extra bottom padding for the input area, so keep this a bit generous. + const threshold = 80; // px tolerance + const distanceFromBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + setPinnedToBottom(distanceFromBottom <= threshold); + }; + + // Initialize once mounted so first render is correct + onScroll(); + container.addEventListener('scroll', onScroll, { passive: true }); + return () => container.removeEventListener('scroll', onScroll); + }, []); + + // When content changes, if the user is pinned to bottom, keep it anchored there. + // Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates. + useLayoutEffect(() => { + const container = messagesContainerRef.current; + if (!container) { + return; + } + + // Detect whether new items were appended (vs. streaming chunk updates) + const prev = prevCountsRef.current; + const newMsg = messageHandling.messages.length > prev.msgLen; + const newInProg = inProgressToolCalls.length > prev.inProgLen; + const newDone = completedToolCalls.length > prev.doneLen; + prevCountsRef.current = { + msgLen: messageHandling.messages.length, + inProgLen: inProgressToolCalls.length, + doneLen: completedToolCalls.length, + }; + + if (!pinnedToBottom) { + // Do nothing if user scrolled away; avoid stealing scroll. + return; + } + + const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks + + // Anchor to the bottom on next frame to avoid layout thrash. + const raf = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + // Use scrollTo to avoid cross-context issues with scrollIntoView. + container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); + }); + return () => cancelAnimationFrame(raf); + }, [ + pinnedToBottom, + messageHandling.messages, + inProgressToolCalls, + completedToolCalls, + messageHandling.isWaitingForResponse, + messageHandling.loadingMessage, + messageHandling.isStreaming, + planEntries, + ]); + + // When the last rendered item resizes (e.g., images/code blocks load/expand), + // if we're pinned to bottom, keep it anchored there. + useEffect(() => { + const container = messagesContainerRef.current; + const endEl = messagesEndRef.current; + if (!container || !endEl) { + return; + } + + const lastItem = endEl.previousElementSibling as HTMLElement | null; + if (!lastItem) { + return; + } + + let frame = 0; + const ro = new ResizeObserver(() => { + if (!pinnedToBottom) { + return; + } + // Defer to next frame to avoid thrash during rapid size changes + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + container.scrollTo({ top }); + }); + }); + ro.observe(lastItem); + + return () => { + cancelAnimationFrame(frame); + ro.disconnect(); + }; + }, [ + pinnedToBottom, + messageHandling.messages, + inProgressToolCalls, + completedToolCalls, + ]); + + // Handle permission response + const handlePermissionResponse = useCallback( + (optionId: string) => { + // Forward the selected optionId directly to extension as ACP permission response + // Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc. + vscode.postMessage({ + type: 'permissionResponse', + data: { optionId }, + }); + setPermissionRequest(null); + }, + [vscode], + ); + + // Handle completion selection + const handleCompletionSelect = useCallback( + (item: CompletionItem) => { + // Handle completion selection by inserting the value into the input field + const inputElement = inputFieldRef.current; + if (!inputElement) { + return; + } + + // Ignore info items (placeholders like "Searching files…") + if (item.type === 'info') { + completion.closeCompletion(); + return; + } + + // Slash commands can execute immediately + if (item.type === 'command') { + const command = (item.label || '').trim(); + if (command === '/login') { + vscode.postMessage({ type: 'login', data: {} }); + completion.closeCompletion(); + return; + } + } + + // If selecting a file, add @filename -> fullpath mapping + if (item.type === 'file' && item.value && item.path) { + try { + fileContext.addFileReference(item.value, item.path); + } catch (err) { + console.warn('[App] addFileReference failed:', err); + } + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + // Current text and cursor + const text = inputElement.textContent || ''; + const range = selection.getRangeAt(0); + + // Compute total text offset for contentEditable + let cursorPos = text.length; + if (range.startContainer === inputElement) { + const childIndex = range.startOffset; + let offset = 0; + for ( + let i = 0; + i < childIndex && i < inputElement.childNodes.length; + i++ + ) { + offset += inputElement.childNodes[i].textContent?.length || 0; + } + cursorPos = offset || text.length; + } else if (range.startContainer.nodeType === Node.TEXT_NODE) { + const walker = document.createTreeWalker( + inputElement, + NodeFilter.SHOW_TEXT, + null, + ); + let offset = 0; + let found = false; + let node: Node | null = walker.nextNode(); + while (node) { + if (node === range.startContainer) { + offset += range.startOffset; + found = true; + break; + } + offset += node.textContent?.length || 0; + node = walker.nextNode(); + } + cursorPos = found ? offset : text.length; + } + + // Replace from trigger to cursor with selected value + const textBeforeCursor = text.substring(0, cursorPos); + const atPos = textBeforeCursor.lastIndexOf('@'); + const slashPos = textBeforeCursor.lastIndexOf('/'); + const triggerPos = Math.max(atPos, slashPos); + + if (triggerPos >= 0) { + const insertValue = + typeof item.value === 'string' ? item.value : String(item.label); + const newText = + text.substring(0, triggerPos + 1) + // keep the trigger symbol + insertValue + + ' ' + + text.substring(cursorPos); + + // Update DOM and state, and move caret to end + inputElement.textContent = newText; + setInputText(newText); + + const newRange = document.createRange(); + const sel = window.getSelection(); + newRange.selectNodeContents(inputElement); + newRange.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(newRange); + } + + // Close the completion menu + completion.closeCompletion(); + }, + [completion, inputFieldRef, setInputText, fileContext, vscode], + ); + + // Handle attach context click + const handleAttachContextClick = useCallback(() => { + // Open native file picker (different from '@' completion which searches workspace files) + vscode.postMessage({ + type: 'attachFile', + data: {}, + }); + }, [vscode]); + + // Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default) + const handleToggleEditMode = useCallback(() => { + setEditMode((prev) => { + const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev]; + + // Notify extension to set approval mode via ACP + try { + vscode.postMessage({ + type: 'setApprovalMode', + data: { modeId: next }, + }); + } catch { + /* no-op */ + } + return next; + }); + }, [vscode]); + + // Handle toggle thinking + const handleToggleThinking = () => { + setThinkingEnabled((prev) => !prev); + }; + + // Create unified message array containing all types of messages and tool calls + const allMessages = useMemo< + Array<{ + type: 'message' | 'in-progress-tool-call' | 'completed-tool-call'; + data: TextMessage | ToolCallData; + timestamp: number; + }> + >(() => { + // Regular messages + const regularMessages = messageHandling.messages.map((msg) => ({ + type: 'message' as const, + data: msg, + timestamp: msg.timestamp, + })); + + // In-progress tool calls + const inProgressTools = inProgressToolCalls.map((toolCall) => ({ + type: 'in-progress-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Completed tool calls + const completedTools = completedToolCalls + .filter(hasToolCallOutput) + .map((toolCall) => ({ + type: 'completed-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Merge and sort by timestamp to ensure messages and tool calls are interleaved + return [...regularMessages, ...inProgressTools, ...completedTools].sort( + (a, b) => (a.timestamp || 0) - (b.timestamp || 0), + ); + }, [messageHandling.messages, inProgressToolCalls, completedToolCalls]); + + console.log('[App] Rendering messages:', allMessages); + + // Render all messages and tool calls + const renderMessages = useCallback<() => React.ReactNode>( + () => + allMessages.map((item, index) => { + switch (item.type) { + case 'message': { + const msg = item.data as TextMessage; + const handleFileClick = (path: string): void => { + vscode.postMessage({ + type: 'openFile', + data: { path }, + }); + }; + + if (msg.role === 'thinking') { + return ( + + ); + } + + if (msg.role === 'user') { + return ( + + ); + } + + { + const content = (msg.content || '').trim(); + if (content === 'Interrupted' || content === 'Tool interrupted') { + return ( + + ); + } + return ( + + ); + } + } + + case 'in-progress-tool-call': + case 'completed-tool-call': { + const prev = allMessages[index - 1]; + const next = allMessages[index + 1]; + const isToolCallType = ( + x: unknown, + ): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } => + !!x && + typeof x === 'object' && + 'type' in (x as Record) && + ((x as { type: string }).type === 'in-progress-tool-call' || + (x as { type: string }).type === 'completed-tool-call'); + const isFirst = !isToolCallType(prev); + const isLast = !isToolCallType(next); + return ( + + ); + } + + default: + return null; + } + }), + [allMessages, vscode], + ); + + const hasContent = + messageHandling.messages.length > 0 || + messageHandling.isStreaming || + inProgressToolCalls.length > 0 || + completedToolCalls.length > 0 || + planEntries.length > 0 || + allMessages.length > 0; + + return ( +
+ { + sessionManagement.handleSwitchSession(sessionId); + sessionManagement.setSessionSearchQuery(''); + }} + onClose={() => sessionManagement.setShowSessionSelector(false)} + hasMore={sessionManagement.hasMore} + isLoading={sessionManagement.isLoading} + onLoadMore={sessionManagement.handleLoadMoreSessions} + /> + + + +
+ {!hasContent ? ( + + ) : ( + <> + {/* 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 && ( + + )} +
+ +
+ + )} +
+ + 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(); + + 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 { + 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 }; + } + } 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} + /> + + {permissionRequest && ( + setPermissionRequest(null)} + /> + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts new file mode 100644 index 00000000..1eca4a20 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QwenAgentManager } from '../services/qwenAgentManager.js'; +import type { ConversationStore } from '../services/conversationStore.js'; +import { MessageRouter } from './handlers/MessageRouter.js'; + +/** + * MessageHandler (Refactored Version) + * This is a lightweight wrapper class that internally uses MessageRouter and various sub-handlers + * Maintains interface compatibility with the original code + */ +export class MessageHandler { + private router: MessageRouter; + + constructor( + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.router = new MessageRouter( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + } + + /** + * Route messages to the corresponding handler + */ + async route(message: { type: string; data?: unknown }): Promise { + await this.router.route(message); + } + + /** + * Set current session ID + */ + setCurrentConversationId(id: string | null): void { + this.router.setCurrentConversationId(id); + } + + /** + * Get current session ID + */ + getCurrentConversationId(): string | null { + return this.router.getCurrentConversationId(); + } + + /** + * Set permission handler + */ + setPermissionHandler( + handler: (message: { type: string; data: { optionId: string } }) => void, + ): void { + this.router.setPermissionHandler(handler); + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.router.setLoginHandler(handler); + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.router.appendStreamContent(chunk); + } + + /** + * Check if saving checkpoint + */ + getIsSavingCheckpoint(): boolean { + return this.router.getIsSavingCheckpoint(); + } +} diff --git a/packages/vscode-ide-companion/src/webview/PanelManager.ts b/packages/vscode-ide-companion/src/webview/PanelManager.ts new file mode 100644 index 00000000..44f1a6ec --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/PanelManager.ts @@ -0,0 +1,385 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; + +/** + * Panel and Tab Manager + * Responsible for managing the creation, display, and tab tracking of WebView Panels + */ +export class PanelManager { + private panel: vscode.WebviewPanel | null = null; + private panelTab: vscode.Tab | null = null; + // Best-effort tracking of the group (by view column) that currently hosts + // the Qwen webview. We update this when creating/revealing the panel and + // whenever we can capture the Tab from the tab model. + private panelGroupViewColumn: vscode.ViewColumn | null = null; + + constructor( + private extensionUri: vscode.Uri, + private onPanelDispose: () => void, + ) {} + + /** + * Get the current Panel + */ + getPanel(): vscode.WebviewPanel | null { + return this.panel; + } + + /** + * Set Panel (for restoration) + */ + setPanel(panel: vscode.WebviewPanel): void { + console.log('[PanelManager] Setting panel for restoration'); + this.panel = panel; + } + + /** + * Create new WebView Panel + * @returns Whether it is a newly created Panel + */ + async createPanel(): Promise { + if (this.panel) { + return false; // Panel already exists + } + + // First, check if there's an existing Qwen Code group + const existingGroup = this.findExistingQwenCodeGroup(); + + if (existingGroup) { + // If Qwen Code webview already exists in a locked group, create the new panel in that same group + console.log( + '[PanelManager] Found existing Qwen Code group, creating panel in same group', + ); + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: existingGroup.viewColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + // Track the group column hosting this panel + this.panelGroupViewColumn = existingGroup.viewColumn; + } else { + // If no existing Qwen Code group, create a new group to the right of the active editor group + try { + // Create a new group to the right of the current active group + await vscode.commands.executeCommand('workbench.action.newGroupRight'); + } catch (error) { + console.warn( + '[PanelManager] Failed to create right editor group (continuing):', + error, + ); + // Fallback: create in current group + const activeColumn = + vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One; + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: activeColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + // Lock the group after creation + await this.autoLockEditorGroup(); + return true; + } + + // Get the new group's view column (should be the active one after creating right) + const newGroupColumn = vscode.window.tabGroups.activeTabGroup.viewColumn; + + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: newGroupColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + + // Lock the group after creation + await this.autoLockEditorGroup(); + + // Track the newly created group's column + this.panelGroupViewColumn = newGroupColumn; + } + + // Set panel icon to Qwen logo + this.panel.iconPath = vscode.Uri.joinPath( + this.extensionUri, + 'assets', + 'icon.png', + ); + + // Try to capture Tab info shortly after creation so we can track the + // precise group even if the user later drags the tab between groups. + this.captureTab(); + + return true; // New panel created + } + + /** + * Find the group and view column where the existing Qwen Code webview is located + * @returns The found group and view column, or undefined if not found + */ + private findExistingQwenCodeGroup(): + | { group: vscode.TabGroup; viewColumn: vscode.ViewColumn } + | undefined { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + + if ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ) { + // Found an existing Qwen Code tab + console.log('[PanelManager] Found existing Qwen Code group:', { + viewColumn: group.viewColumn, + tabCount: group.tabs.length, + isActive: group.isActive, + }); + return { + group, + viewColumn: group.viewColumn, + }; + } + } + } + + return undefined; + } + + /** + * Auto-lock editor group (only called when creating a new Panel) + * After creating/revealing the WebviewPanel, lock the active editor group so + * the group stays dedicated (users can still unlock manually). We still + * temporarily unlock before creation to allow adding tabs to an existing + * group; this method restores the locked state afterwards. + */ + async autoLockEditorGroup(): Promise { + if (!this.panel) { + return; + } + + try { + // The newly created panel is focused (preserveFocus: false), so this + // locks the correct, active editor group. + await vscode.commands.executeCommand('workbench.action.lockEditorGroup'); + console.log('[PanelManager] Group locked after panel creation'); + } catch (error) { + console.warn('[PanelManager] Failed to lock editor group:', error); + } + } + + /** + * Show Panel (reveal if exists, otherwise do nothing) + * @param preserveFocus Whether to preserve focus + */ + revealPanel(preserveFocus: boolean = true): void { + if (this.panel) { + // Prefer revealing in the currently tracked group to avoid reflowing groups. + const trackedColumn = ( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn as vscode.ViewColumn | undefined; + const targetColumn: vscode.ViewColumn = + trackedColumn ?? + this.panelGroupViewColumn ?? + vscode.window.tabGroups.activeTabGroup.viewColumn; + this.panel.reveal(targetColumn, preserveFocus); + } + } + + /** + * Capture the Tab corresponding to the WebView Panel + * Used for tracking and managing Tab state + */ + captureTab(): void { + if (!this.panel) { + return; + } + + // Defer slightly so the tab model is updated after create/reveal + setTimeout(() => { + const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs); + const match = allTabs.find((t) => { + // Type guard for webview tab input + const input: unknown = (t as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + const isWebview = isWebviewInput(input); + const sameViewType = isWebview && input.viewType === 'qwenCode.chat'; + const sameLabel = t.label === this.panel!.title; + return !!(sameViewType || sameLabel); + }); + this.panelTab = match ?? null; + // Update last-known group column if we can read it from the captured tab + try { + const groupViewColumn = ( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn; + if (groupViewColumn !== null) { + this.panelGroupViewColumn = groupViewColumn as vscode.ViewColumn; + } + } catch { + // Best effort only; ignore if the API shape differs + } + }, 50); + } + + /** + * Register the dispose event handler for the Panel + * @param disposables Array used to store Disposable objects + */ + registerDisposeHandler(disposables: vscode.Disposable[]): void { + if (!this.panel) { + return; + } + + this.panel.onDidDispose( + () => { + // Capture the group we intend to clean up before we clear fields + const targetColumn: vscode.ViewColumn | null = + // Prefer the group from the captured tab if available + (( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn as vscode.ViewColumn | undefined) ?? + // Fall back to our last-known group column + this.panelGroupViewColumn ?? + null; + + this.panel = null; + this.panelTab = null; + this.onPanelDispose(); + + // After VS Code updates its tab model, check if that group is now + // empty (and typically locked for Qwen). If so, close the group to + // avoid leaving an empty locked column when the user closes Qwen. + if (targetColumn !== null) { + const column: vscode.ViewColumn = targetColumn; + setTimeout(async () => { + try { + const groups = vscode.window.tabGroups.all; + const group = groups.find((g) => g.viewColumn === column); + // If the group that hosted Qwen is now empty, close it to avoid + // leaving an empty locked column around. VS Code's stable API + // does not expose the lock state on TabGroup, so we only check + // for emptiness here. + if (group && group.tabs.length === 0) { + // Focus the group we want to close + await this.focusGroupByColumn(column); + // Try closeGroup first; fall back to removeActiveEditorGroup + try { + await vscode.commands.executeCommand( + 'workbench.action.closeGroup', + ); + } catch { + try { + await vscode.commands.executeCommand( + 'workbench.action.removeActiveEditorGroup', + ); + } catch (err) { + console.warn( + '[PanelManager] Failed to close empty group after Qwen panel disposed:', + err, + ); + } + } + } + } catch (err) { + console.warn( + '[PanelManager] Error while trying to close empty Qwen group:', + err, + ); + } + }, 50); + } + }, + null, + disposables, + ); + } + + /** + * Focus the editor group at the given view column by stepping left/right. + * This avoids depending on Nth-group focus commands that may not exist. + */ + private async focusGroupByColumn(target: vscode.ViewColumn): Promise { + const maxHops = 20; // safety guard for unusual layouts + let hops = 0; + while ( + vscode.window.tabGroups.activeTabGroup.viewColumn !== target && + hops < maxHops + ) { + const current = vscode.window.tabGroups.activeTabGroup.viewColumn; + if (current < target) { + await vscode.commands.executeCommand( + 'workbench.action.focusRightGroup', + ); + } else if (current > target) { + await vscode.commands.executeCommand('workbench.action.focusLeftGroup'); + } else { + break; + } + hops++; + } + } + + /** + * Register the view state change event handler + * @param disposables Array used to store Disposable objects + */ + registerViewStateChangeHandler(disposables: vscode.Disposable[]): void { + if (!this.panel) { + return; + } + + this.panel.onDidChangeViewState( + () => { + if (this.panel && this.panel.visible) { + this.captureTab(); + } + }, + null, + disposables, + ); + } + + /** + * Dispose Panel + */ + dispose(): void { + this.panel?.dispose(); + this.panel = null; + this.panelTab = null; + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/WebViewContent.ts new file mode 100644 index 00000000..8f802c84 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/WebViewContent.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { escapeHtml } from './utils/webviewUtils.js'; + +/** + * WebView HTML Content Generator + * Responsible for generating the HTML content of the WebView + */ +export class WebViewContent { + /** + * Generate HTML content for the WebView + * @param panel WebView Panel + * @param extensionUri Extension URI + * @returns HTML string + */ + static generate( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + ): string { + const scriptUri = panel.webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'), + ); + + // Convert extension URI for webview access - this allows frontend to construct resource paths + const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri); + + // Escape URI for HTML to prevent potential injection attacks + const safeExtensionUri = escapeHtml(extensionUriForWebview.toString()); + const safeScriptUri = escapeHtml(scriptUri.toString()); + + return ` + + + + + + Qwen Code + + +
+ + +`; + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts new file mode 100644 index 00000000..82629787 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -0,0 +1,1225 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { QwenAgentManager } from '../services/qwenAgentManager.js'; +import { ConversationStore } from '../services/conversationStore.js'; +import type { AcpPermissionRequest } from '../types/acpTypes.js'; +import { CliDetector } from '../cli/cliDetector.js'; +import { AuthStateManager } from '../services/authStateManager.js'; +import { PanelManager } from '../webview/PanelManager.js'; +import { MessageHandler } from '../webview/MessageHandler.js'; +import { WebViewContent } from '../webview/WebViewContent.js'; +import { CliInstaller } from '../cli/cliInstaller.js'; +import { getFileName } from './utils/webviewUtils.js'; +import { authMethod, type ApprovalModeValue } from '../types/acpTypes.js'; + +export class WebViewProvider { + private panelManager: PanelManager; + private messageHandler: MessageHandler; + private agentManager: QwenAgentManager; + private conversationStore: ConversationStore; + private authStateManager: AuthStateManager; + private disposables: vscode.Disposable[] = []; + private agentInitialized = false; // Track if agent has been initialized + // Track a pending permission request and its resolver so extension commands + // can "simulate" user choice from the command palette (e.g. after accepting + // a diff, auto-allow read/execute, or auto-reject on cancel). + private pendingPermissionRequest: AcpPermissionRequest | null = null; + private pendingPermissionResolve: ((optionId: string) => void) | null = null; + // Track current ACP mode id to influence permission/diff behavior + private currentModeId: ApprovalModeValue | null = null; + + constructor( + context: vscode.ExtensionContext, + private extensionUri: vscode.Uri, + ) { + this.agentManager = new QwenAgentManager(); + this.conversationStore = new ConversationStore(context); + this.authStateManager = AuthStateManager.getInstance(context); + this.panelManager = new PanelManager(extensionUri, () => { + // Panel dispose callback + this.disposables.forEach((d) => d.dispose()); + }); + this.messageHandler = new MessageHandler( + this.agentManager, + this.conversationStore, + null, + (message) => this.sendMessageToWebView(message), + ); + + // Set login handler for /login command - direct force re-login + this.messageHandler.setLoginHandler(async () => { + await this.forceReLogin(); + }); + + // Setup agent callbacks + this.agentManager.onMessage((message) => { + // Do not suppress messages during checkpoint saves. + // Checkpoint persistence now writes directly to disk and should not + // generate ACP session/update traffic. Suppressing here could drop + // legitimate history replay messages (e.g., session/load) or + // assistant replies when a new prompt starts while an async save is + // still finishing. + this.sendMessageToWebView({ + type: 'message', + data: message, + }); + }); + + this.agentManager.onStreamChunk((chunk: string) => { + // Always forward stream chunks; do not gate on checkpoint saves. + // See note in onMessage() above. + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'streamChunk', + data: { chunk }, + }); + }); + + // Setup thought chunk handler + this.agentManager.onThoughtChunk((chunk: string) => { + // Always forward thought chunks; do not gate on checkpoint saves. + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'thoughtChunk', + data: { chunk }, + }); + }); + + // Surface available modes and current mode (from ACP initialize) + this.agentManager.onModeInfo((info) => { + try { + const current = (info?.currentModeId || null) as + | 'plan' + | 'default' + | 'auto-edit' + | 'yolo' + | null; + this.currentModeId = current; + } catch (_error) { + // Ignore error when parsing mode info + } + this.sendMessageToWebView({ + type: 'modeInfo', + data: info || {}, + }); + }); + + // Surface mode changes (from ACP or immediate set_mode response) + this.agentManager.onModeChanged((modeId) => { + try { + this.currentModeId = modeId; + } catch (_error) { + // Ignore error when setting mode id + } + this.sendMessageToWebView({ + type: 'modeChanged', + data: { modeId }, + }); + }); + + // Setup end-turn handler from ACP stopReason=end_turn + this.agentManager.onEndTurn(() => { + // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere + this.sendMessageToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'end_turn' }, + }); + }); + + // Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager + // and sent via onStreamChunk callback + this.agentManager.onToolCall((update) => { + // Always surface tool calls; they are part of the live assistant flow. + // Cast update to access sessionUpdate property + const updateData = update as unknown as Record; + + // Determine message type from sessionUpdate field + // If sessionUpdate is missing, infer from content: + // - If has kind/title/rawInput, it's likely initial tool_call + // - If only has status/content updates, it's tool_call_update + let messageType = updateData.sessionUpdate as string | undefined; + if (!messageType) { + // Infer type: if has kind or title, assume initial call; otherwise update + if (updateData.kind || updateData.title || updateData.rawInput) { + messageType = 'tool_call'; + } else { + messageType = 'tool_call_update'; + } + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: messageType, + ...updateData, + }, + }); + }); + + // Setup plan handler + this.agentManager.onPlan((entries) => { + this.sendMessageToWebView({ + type: 'plan', + data: { entries }, + }); + }); + + this.agentManager.onPermissionRequest( + async (request: AcpPermissionRequest) => { + // Auto-approve in auto/yolo mode (no UI, no diff) + if (this.isAutoMode()) { + const options = request.options || []; + const pick = (substr: string) => + options.find((o) => + (o.optionId || '').toLowerCase().includes(substr), + )?.optionId; + const pickByKind = (k: string) => + options.find((o) => (o.kind || '').toLowerCase().includes(k)) + ?.optionId; + const optionId = + pick('allow_once') || + pickByKind('allow') || + pick('proceed') || + options[0]?.optionId || + 'allow_once'; + return optionId; + } + + // Send permission request to WebView + this.sendMessageToWebView({ + type: 'permissionRequest', + data: request, + }); + + // Wait for user response + return new Promise((resolve) => { + // cache the pending request and its resolver so commands can resolve it + this.pendingPermissionRequest = request; + this.pendingPermissionResolve = (optionId: string) => { + try { + resolve(optionId); + } finally { + // Always clear pending state + this.pendingPermissionRequest = null; + this.pendingPermissionResolve = null; + // Also instruct the webview UI to close its drawer if it is open + this.sendMessageToWebView({ + type: 'permissionResolved', + data: { optionId }, + }); + // If allowed/proceeded, close any open qwen-diff editors and suppress re-open briefly + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + if (!isCancel) { + try { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to close diffs after allow (resolver):', + err, + ); + } + try { + void vscode.commands.executeCommand( + 'qwen.diff.suppressBriefly', + ); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to suppress diffs briefly:', + err, + ); + } + } + } + }; + const handler = (message: { + type: string; + data: { optionId: string }; + }) => { + if (message.type !== 'permissionResponse') { + return; + } + + const optionId = message.data.optionId || ''; + + // 1) First resolve the optionId back to ACP so the agent isn't blocked + this.pendingPermissionResolve?.(optionId); + + // 2) If user cancelled/rejected, proactively stop current generation + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + + if (isCancel) { + // Fire and forget – do not block the ACP resolve + (async () => { + try { + // Stop server-side generation + await this.agentManager.cancelCurrentPrompt(); + } catch (err) { + console.warn( + '[WebViewProvider] cancelCurrentPrompt error:', + err, + ); + } + + // Ensure the webview exits streaming state immediately + this.sendMessageToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + + // Synthesize a failed tool_call_update to match CLI UX + try { + const toolCallId = + (request.toolCall as { toolCallId?: string } | undefined) + ?.toolCallId || ''; + const title = + (request.toolCall as { title?: string } | undefined) + ?.title || ''; + // Normalize kind for UI – fall back to 'execute' + let kind = (( + request.toolCall as { kind?: string } | undefined + )?.kind || 'execute') as string; + if (!kind && title) { + const t = title.toLowerCase(); + if (t.includes('read') || t.includes('cat')) { + kind = 'read'; + } else if (t.includes('write') || t.includes('edit')) { + kind = 'edit'; + } else { + kind = 'execute'; + } + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: 'tool_call_update', + toolCallId, + title, + kind, + status: 'failed', + // Best-effort pass-through (used by UI hints) + rawInput: (request.toolCall as { rawInput?: unknown }) + ?.rawInput, + locations: ( + request.toolCall as { + locations?: Array<{ + path: string; + line?: number | null; + }>; + } + )?.locations, + }, + }); + } catch (err) { + console.warn( + '[WebViewProvider] failed to synthesize failed tool_call_update:', + err, + ); + } + })(); + } + // If user allowed/proceeded, proactively close any open qwen-diff editors and suppress re-open briefly + else { + try { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to close diffs after allow:', + err, + ); + } + try { + void vscode.commands.executeCommand( + 'qwen.diff.suppressBriefly', + ); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to suppress diffs briefly:', + err, + ); + } + } + }; + // Store handler in message handler + this.messageHandler.setPermissionHandler(handler); + }); + }, + ); + } + + async show(): Promise { + const panel = this.panelManager.getPanel(); + + if (panel) { + // Reveal the existing panel + this.panelManager.revealPanel(true); + this.panelManager.captureTab(); + return; + } + + // Create new panel + const isNewPanel = await this.panelManager.createPanel(); + + if (!isNewPanel) { + return; // Failed to create panel + } + + const newPanel = this.panelManager.getPanel(); + if (!newPanel) { + return; + } + + // Set up state serialization + newPanel.onDidChangeViewState(() => { + console.log( + '[WebViewProvider] Panel view state changed, triggering serialization check', + ); + }); + + // Capture the Tab that corresponds to our WebviewPanel + this.panelManager.captureTab(); + + // Auto-lock editor group when opened in new column + await this.panelManager.autoLockEditorGroup(); + + newPanel.webview.html = WebViewContent.generate( + newPanel, + this.extensionUri, + ); + + // Handle messages from WebView + newPanel.webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + // Allow webview to request updating the VS Code tab title + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + const panelRef = this.panelManager.getPanel(); + if (panelRef) { + panelRef.title = title || 'Qwen Code'; + } + return; + } + await this.messageHandler.route(message); + }, + null, + this.disposables, + ); + + // Listen for view state changes (no pin/lock; just keep tab reference fresh) + this.panelManager.registerViewStateChangeHandler(this.disposables); + + // Register panel dispose handler + this.panelManager.registerDisposeHandler(this.disposables); + + // Listen for active editor changes and notify WebView + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + // Listen for text selection changes + const selectionChangeDisposable = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + + // Mode callbacks are registered in constructor; no-op here + } + }); + this.disposables.push(selectionChangeDisposable); + + // Send initial active editor state to WebView + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + + // Attempt to restore authentication state and initialize connection + console.log( + '[WebViewProvider] Attempting to restore auth state and connection...', + ); + await this.attemptAuthStateRestoration(); + } + + /** + * Attempt to restore authentication state and initialize connection + * This is called when the webview is first shown + */ + 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 + await this.initializeEmptyConversation(); + } + } + + /** + * 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(); + + console.log( + '[WebViewProvider] Starting initialization, workingDir:', + workingDir, + ); + console.log( + '[WebViewProvider] AuthStateManager available:', + !!this.authStateManager, + ); + + // 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', + ); + 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...', + ); + console.log('[WebViewProvider] CLI path:', cliDetection.cliPath); + console.log('[WebViewProvider] CLI version:', cliDetection.version); + + 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( + workingDir, + this.authStateManager, + cliDetection.cliPath, + ); + console.log('[WebViewProvider] Agent connected successfully'); + this.agentInitialized = true; + + // Load messages from the current Qwen session + await this.loadCurrentSessionMessages(); + + // Notify webview that agent is connected + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } 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.`, + ); + // Fallback to empty conversation + await this.initializeEmptyConversation(); + + // Notify webview that agent connection failed + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: _error instanceof Error ? _error.message : String(_error), + }, + }); + } + } + } + + /** + * Force re-login by clearing auth cache and reconnecting + * Called when user explicitly uses /login command + */ + async forceReLogin(): Promise { + console.log('[WebViewProvider] Force re-login requested'); + console.log( + '[WebViewProvider] Current authStateManager:', + !!this.authStateManager, + ); + + await 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 { + this.agentManager.disconnect(); + console.log('[WebViewProvider] Existing connection disconnected'); + } catch (_error) { + console.log('[WebViewProvider] Error disconnecting:', _error); + } + this.agentInitialized = false; + } + + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 300)); + + progress.report({ + message: 'Connecting to CLI and starting sign-in...', + }); + + // Reinitialize connection (will trigger fresh authentication) + await this.initializeAgentConnection(); + 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', + data: { message: 'Successfully logged in!' }, + }); + } catch (_error) { + console.error('[WebViewProvider] Force re-login failed:', _error); + console.error( + '[WebViewProvider] Error stack:', + _error instanceof Error ? _error.stack : 'N/A', + ); + + // Send error notification to WebView + this.sendMessageToWebView({ + type: 'loginError', + data: { + message: `Login failed: ${_error instanceof Error ? _error.message : String(_error)}`, + }, + }); + + throw _error; + } + }, + ); + } + + /** + * Refresh connection without clearing auth cache + * Called when restoring WebView after VSCode restart + */ + async refreshConnection(): Promise { + console.log('[WebViewProvider] Refresh connection requested'); + + // Disconnect existing connection if any + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + console.log('[WebViewProvider] Existing connection disconnected'); + } catch (_error) { + console.log('[WebViewProvider] Error disconnecting:', _error); + } + this.agentInitialized = false; + } + + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Reinitialize connection (will use cached auth if available) + try { + await this.initializeAgentConnection(); + console.log( + '[WebViewProvider] Connection refresh completed successfully', + ); + + // Notify webview that agent is connected after refresh + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } catch (_error) { + console.error('[WebViewProvider] Connection refresh failed:', _error); + + // Notify webview that agent connection failed after refresh + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: _error instanceof Error ? _error.message : String(_error), + }, + }); + + throw _error; + } + } + + /** + * Load messages from current Qwen session + * Skips session restoration and creates a new session directly + */ + private async loadCurrentSessionMessages(): Promise { + try { + console.log( + '[WebViewProvider] Initializing with new session (skipping restoration)', + ); + + 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); + console.log( + '[WebViewProvider] Auth state saved after session creation', + ); + } + } 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.`, + ); + } + + await this.initializeEmptyConversation(); + } catch (_error) { + console.error( + '[WebViewProvider] Failed to load session messages:', + _error, + ); + vscode.window.showErrorMessage( + `Failed to load session messages: ${_error}`, + ); + await this.initializeEmptyConversation(); + } + } + + /** + * Initialize an empty conversation + * Creates a new conversation and notifies WebView + */ + private async initializeEmptyConversation(): Promise { + try { + console.log('[WebViewProvider] Initializing empty conversation'); + const newConv = await this.conversationStore.createConversation(); + this.messageHandler.setCurrentConversationId(newConv.id); + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + console.log( + '[WebViewProvider] Empty conversation initialized:', + this.messageHandler.getCurrentConversationId(), + ); + } catch (_error) { + console.error( + '[WebViewProvider] Failed to initialize conversation:', + _error, + ); + // Send empty state to WebView as fallback + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: { id: 'temp', messages: [] }, + }); + } + } + + /** + * Send message to WebView + */ + private sendMessageToWebView(message: unknown): void { + const panel = this.panelManager.getPanel(); + panel?.webview.postMessage(message); + } + + /** + * Whether there is a pending permission decision awaiting an option. + */ + hasPendingPermission(): boolean { + return !!this.pendingPermissionResolve; + } + + /** Get current ACP mode id (if known). */ + getCurrentModeId(): ApprovalModeValue | null { + return this.currentModeId; + } + + /** True if diffs/permissions should be auto-handled without prompting. */ + isAutoMode(): boolean { + return this.currentModeId === 'auto-edit' || this.currentModeId === 'yolo'; + } + + /** Used by extension to decide if diffs should be suppressed. */ + shouldSuppressDiff(): boolean { + return this.isAutoMode(); + } + + /** + * Simulate selecting a permission option while a request drawer is open. + * The choice can be a concrete optionId or a shorthand intent. + */ + respondToPendingPermission( + choice: { optionId: string } | 'accept' | 'allow' | 'reject' | 'cancel', + ): void { + if (!this.pendingPermissionResolve || !this.pendingPermissionRequest) { + return; // nothing to do + } + + const options = this.pendingPermissionRequest.options || []; + + const pickByKind = (substr: string, preferOnce = false) => { + const lc = substr.toLowerCase(); + const filtered = options.filter((o) => + (o.kind || '').toLowerCase().includes(lc), + ); + if (preferOnce) { + const once = filtered.find((o) => + (o.optionId || '').toLowerCase().includes('once'), + ); + if (once) { + return once.optionId; + } + } + return filtered[0]?.optionId; + }; + + const pickByOptionId = (substr: string) => + options.find((o) => (o.optionId || '').toLowerCase().includes(substr)) + ?.optionId; + + let optionId: string | undefined; + + if (typeof choice === 'object') { + optionId = choice.optionId; + } else { + const c = choice.toLowerCase(); + if (c === 'accept' || c === 'allow') { + // Prefer an allow_once/proceed_once style option, then any allow/proceed + optionId = + pickByKind('allow', true) || + pickByOptionId('proceed_once') || + pickByKind('allow') || + pickByOptionId('proceed') || + options[0]?.optionId; // last resort: first option + } else if (c === 'cancel' || c === 'reject') { + // Prefer explicit cancel, then a reject option + optionId = + options.find((o) => o.optionId === 'cancel')?.optionId || + pickByKind('reject') || + pickByOptionId('cancel') || + pickByOptionId('reject') || + 'cancel'; + } + } + + if (!optionId) { + return; + } + + try { + this.pendingPermissionResolve(optionId); + } catch (_error) { + console.warn( + '[WebViewProvider] respondToPendingPermission failed:', + _error, + ); + } + } + + /** + * Reset agent initialization state + * Call this when auth cache is cleared to force re-authentication + */ + resetAgentState(): void { + console.log('[WebViewProvider] Resetting agent state'); + this.agentInitialized = false; + // Disconnect existing connection + 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 + */ + async restorePanel(panel: vscode.WebviewPanel): Promise { + console.log('[WebViewProvider] Restoring WebView panel'); + console.log( + '[WebViewProvider] Current authStateManager in restore:', + !!this.authStateManager, + ); + this.panelManager.setPanel(panel); + + // Ensure restored tab title starts from default label + try { + panel.title = 'Qwen Code'; + } catch (e) { + console.warn( + '[WebViewProvider] Failed to reset restored panel title:', + e, + ); + } + + panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + + // Handle messages from WebView (restored panel) + panel.webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + const panelRef = this.panelManager.getPanel(); + if (panelRef) { + panelRef.title = title || 'Qwen Code'; + } + return; + } + await this.messageHandler.route(message); + }, + null, + this.disposables, + ); + + // Register view state change handler + this.panelManager.registerViewStateChangeHandler(this.disposables); + + // Register dispose handler + this.panelManager.registerDisposeHandler(this.disposables); + + // Listen for active editor changes and notify WebView + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + // Send initial active editor state to WebView + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + + // Listen for text selection changes (restore path) + const selectionChangeDisposableRestore = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + }); + this.disposables.push(selectionChangeDisposableRestore); + + // Capture the tab reference on restore + this.panelManager.captureTab(); + + console.log('[WebViewProvider] Panel restored successfully'); + + // Attempt to restore authentication state and initialize connection + console.log( + '[WebViewProvider] Attempting to restore auth state and connection after restore...', + ); + await this.attemptAuthStateRestoration(); + } + + /** + * Get the current state for serialization + * This is used when VSCode restarts to restore the WebView + */ + getState(): { + conversationId: string | null; + agentInitialized: boolean; + } { + console.log('[WebViewProvider] Getting state for serialization'); + console.log( + '[WebViewProvider] Current conversationId:', + this.messageHandler.getCurrentConversationId(), + ); + console.log( + '[WebViewProvider] Current agentInitialized:', + this.agentInitialized, + ); + const state = { + conversationId: this.messageHandler.getCurrentConversationId(), + agentInitialized: this.agentInitialized, + }; + console.log('[WebViewProvider] Returning state:', state); + return state; + } + + /** + * Get the current panel + */ + getPanel(): vscode.WebviewPanel | null { + return this.panelManager.getPanel(); + } + + /** + * Restore state after VSCode restart + */ + restoreState(state: { + conversationId: string | null; + agentInitialized: boolean; + }): void { + console.log('[WebViewProvider] Restoring state:', state); + this.messageHandler.setCurrentConversationId(state.conversationId); + this.agentInitialized = state.agentInitialized; + console.log( + '[WebViewProvider] State restored. agentInitialized:', + this.agentInitialized, + ); + + // Reload content after restore + const panel = this.panelManager.getPanel(); + if (panel) { + panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + } + } + + /** + * Create a new session in the current panel + * This is called when the user clicks the "New Session" button + */ + async createNewSession(): Promise { + // WebView mode - create new session via agent manager + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // Create new Qwen session via agent manager + await this.agentManager.createNewSession( + workingDir, + this.authStateManager, + ); + + // 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}`); + } + } + + /** + * Dispose the WebView provider and clean up resources + */ + dispose(): void { + this.panelManager.dispose(); + this.agentManager.disconnect(); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx new file mode 100644 index 00000000..00e5bcca --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx @@ -0,0 +1,312 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useState, useRef } from 'react'; +import type { PermissionOption, ToolCall } from './PermissionRequest.js'; + +interface PermissionDrawerProps { + isOpen: boolean; + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; + onClose?: () => void; +} + +export const PermissionDrawer: React.FC = ({ + isOpen, + options, + toolCall, + onResponse, + onClose, +}) => { + const [focusedIndex, setFocusedIndex] = useState(0); + const [customMessage, setCustomMessage] = useState(''); + const containerRef = useRef(null); + // Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting + const customInputRef = useRef(null); + + console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall); + // Prefer file name from locations, fall back to content[].path if present + const getAffectedFileName = (): string => { + const fromLocations = toolCall.locations?.[0]?.path; + if (fromLocations) { + return fromLocations.split('/').pop() || fromLocations; + } + // Some tool calls (e.g. write/edit with diff content) only include path in content + const fromContent = Array.isArray(toolCall.content) + ? ( + toolCall.content.find( + (c: unknown) => + typeof c === 'object' && + c !== null && + 'path' in (c as Record), + ) as { path?: unknown } | undefined + )?.path + : undefined; + if (typeof fromContent === 'string' && fromContent.length > 0) { + return fromContent.split('/').pop() || fromContent; + } + return 'file'; + }; + + // Get the title for the permission request + const getTitle = () => { + if (toolCall.kind === 'edit' || toolCall.kind === 'write') { + const fileName = getAffectedFileName(); + return ( + <> + Make this edit to{' '} + + {fileName} + + ? + + ); + } + if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { + return 'Allow this bash command?'; + } + if (toolCall.kind === 'read') { + const fileName = getAffectedFileName(); + return ( + <> + Allow read from{' '} + + {fileName} + + ? + + ); + } + return toolCall.title || 'Permission Required'; + }; + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) { + return; + } + + // Number keys 1-9 for quick select + const numMatch = e.key.match(/^[1-9]$/); + if ( + numMatch && + !customInputRef.current?.contains(document.activeElement) + ) { + const index = parseInt(e.key, 10) - 1; + if (index < options.length) { + e.preventDefault(); + onResponse(options[index].optionId); + } + return; + } + + // Arrow keys for navigation + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const totalItems = options.length + 1; // +1 for custom input + if (e.key === 'ArrowDown') { + setFocusedIndex((prev) => (prev + 1) % totalItems); + } else { + setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); + } + } + + // Enter to select + if ( + e.key === 'Enter' && + !customInputRef.current?.contains(document.activeElement) + ) { + e.preventDefault(); + if (focusedIndex < options.length) { + onResponse(options[focusedIndex].optionId); + } + } + + // Escape to cancel permission and close (align with CLI behavior) + if (e.key === 'Escape') { + e.preventDefault(); + const rejectOptionId = + options.find((o) => o.kind.includes('reject'))?.optionId || + options.find((o) => o.optionId === 'cancel')?.optionId || + 'cancel'; + onResponse(rejectOptionId); + if (onClose) { + onClose(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, options, onResponse, onClose, focusedIndex]); + + // Focus container when opened + useEffect(() => { + if (isOpen && containerRef.current) { + containerRef.current.focus(); + } + }, [isOpen]); + + // Reset focus to the first option when the drawer opens or the options change + useEffect(() => { + if (isOpen) { + setFocusedIndex(0); + } + }, [isOpen, options.length]); + + if (!isOpen) { + return null; + } + + return ( +
+ {/* Main container */} +
+ {/* Background layer */} +
+ + {/* Title + Description (from toolCall.title) */} +
+
+ {getTitle()} +
+ {(toolCall.kind === 'edit' || + toolCall.kind === 'write' || + toolCall.kind === 'read' || + toolCall.kind === 'execute' || + toolCall.kind === 'bash') && + toolCall.title && ( +
+ {toolCall.title} +
+ )} +
+ + {/* Options */} +
+ {options.map((option, index) => { + const isFocused = focusedIndex === index; + + return ( + + ); + })} + + {/* Custom message input (extracted component) */} + {(() => { + const isFocused = focusedIndex === options.length; + const rejectOptionId = options.find((o) => + o.kind.includes('reject'), + )?.optionId; + return ( + setFocusedIndex(options.length)} + onSubmitReject={() => { + if (rejectOptionId) { + onResponse(rejectOptionId); + } + }} + inputRef={customInputRef} + /> + ); + })()} +
+
+ + {/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */} +
+ ); +}; + +/** + * CustomMessageInputRow: Reusable custom input row component (without hooks) + */ +interface CustomMessageInputRowProps { + isFocused: boolean; + customMessage: string; + setCustomMessage: (val: string) => void; + onFocusRow: () => void; // Set focus when mouse enters or input box is focused + onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option) + inputRef: React.RefObject; +} + +const CustomMessageInputRow: React.FC = ({ + isFocused, + customMessage, + setCustomMessage, + onFocusRow, + onSubmitReject, + inputRef, +}) => ( +
inputRef.current?.focus()} + > + | undefined} + type="text" + placeholder="Tell Qwen what to do instead" + spellCheck={false} + className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70" + style={{ color: 'var(--app-input-foreground)' }} + value={customMessage} + onChange={(e) => setCustomMessage(e.target.value)} + onFocus={onFocusRow} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { + e.preventDefault(); + onSubmitReject(); + } + }} + /> +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx new file mode 100644 index 00000000..a7b7356c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface PermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface ToolCall { + title?: string; + kind?: string; + toolCallId?: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + content?: Array<{ + type: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + status?: string; +} + +export interface PermissionRequestProps { + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; +} diff --git a/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx new file mode 100644 index 00000000..f5e12b33 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit mode related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Edit pencil icon (16x16) + * Used for "Ask before edits" mode + */ +export const EditPencilIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Auto/fast-forward icon (16x16) + * Used for "Edit automatically" mode + */ +export const AutoEditIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Plan mode/bars icon (16x16) + * Used for "Plan mode" + */ +export const PlanModeIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Code brackets icon (20x20) + * Used for active file indicator + */ +export const CodeBracketsIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Hide context (eye slash) icon (20x20) + * Used to indicate the active selection will NOT be auto-loaded into context + */ +export const HideContextIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Slash command icon (20x20) + * Used for command menu button + */ +export const SlashCommandIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Link/attachment icon (20x20) + * Used for attach context button + */ +export const LinkIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Open diff icon (16x16) + * Used for opening diff in VS Code + */ +export const OpenDiffIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx new file mode 100644 index 00000000..38bf27f7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * File and document related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * File document icon (16x16) + * Used for file completion menu + */ +export const FileIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const FileListIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Save document icon (16x16) + * Used for save session button + */ +export const SaveDocumentIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Folder icon (16x16) + * Useful for directory entries in completion lists + */ +export const FolderIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx new file mode 100644 index 00000000..9a4e52fb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Navigation and action icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Chevron down icon (20x20) + * Used for dropdown arrows + */ +export const ChevronDownIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Plus icon (20x20) + * Used for new session button + */ +export const PlusIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Small plus icon (16x16) + * Used for default attachment type + */ +export const PlusSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Arrow up icon (20x20) + * Used for send message button + */ +export const ArrowUpIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Close X icon (14x14) + * Used for close buttons in banners and dialogs + */ +export const CloseIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +export const CloseSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Search/magnifying glass icon (20x20) + * Used for search input + */ +export const SearchIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Refresh/reload icon (16x16) + * Used for refresh session list + */ +export const RefreshIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx new file mode 100644 index 00000000..48c5db84 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Special UI icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +interface ThinkingIconProps extends IconProps { + /** + * Whether thinking is enabled (affects styling) + */ + enabled?: boolean; +} + +export const ThinkingIcon: React.FC = ({ + size = 16, + className, + enabled = false, + style, + ...props +}) => ( + +); + +export const TerminalIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx new file mode 100644 index 00000000..fdaa2943 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Status and state related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Plan completed icon (14x14) + * Used for completed plan items + */ +export const PlanCompletedIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan in progress icon (14x14) + * Used for in-progress plan items + */ +export const PlanInProgressIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan pending icon (14x14) + * Used for pending plan items + */ +export const PlanPendingIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Warning triangle icon (20x20) + * Used for warning messages + */ +export const WarningTriangleIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * User profile icon (16x16) + * Used for login command + */ +export const UserIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const SymbolIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const SelectionIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx b/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx new file mode 100644 index 00000000..40c23250 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Stop icon for canceling operations + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Stop/square icon (16x16) + * Used for stop/cancel operations + */ +export const StopIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/index.ts b/packages/vscode-ide-companion/src/webview/components/icons/index.ts new file mode 100644 index 00000000..ffecbbce --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/index.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { IconProps } from './types.js'; +export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js'; + +// Navigation icons +export { + ChevronDownIcon, + PlusIcon, + PlusSmallIcon, + ArrowUpIcon, + CloseIcon, + CloseSmallIcon, + SearchIcon, + RefreshIcon, +} from './NavigationIcons.js'; + +// Edit mode icons +export { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + HideContextIcon, + SlashCommandIcon, + LinkIcon, + OpenDiffIcon, +} from './EditIcons.js'; + +// Status icons +export { + PlanCompletedIcon, + PlanInProgressIcon, + PlanPendingIcon, + WarningTriangleIcon, + UserIcon, + SymbolIcon, + SelectionIcon, +} from './StatusIcons.js'; + +// Special icons +export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js'; + +// Stop icon +export { StopIcon } from './StopIcon.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/icons/types.ts b/packages/vscode-ide-companion/src/webview/components/icons/types.ts new file mode 100644 index 00000000..6290d720 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/types.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Common icon props interface + */ + +import type React from 'react'; + +export interface IconProps extends React.SVGProps { + /** + * Icon size (width and height) + * @default 16 + */ + size?: number; + + /** + * Additional CSS classes + */ + className?: string; +} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx new file mode 100644 index 00000000..82cc905f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { ChevronDownIcon, PlusIcon } from '../icons/index.js'; + +interface ChatHeaderProps { + currentSessionTitle: string; + onLoadSessions: () => void; + onNewSession: () => void; +} + +export const ChatHeader: React.FC = ({ + currentSessionTitle, + onLoadSessions, + onNewSession, +}) => ( +
+ + +
+ + +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx new file mode 100644 index 00000000..167a376d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; + +interface CompletionMenuProps { + items: CompletionItem[]; + onSelect: (item: CompletionItem) => void; + onClose: () => void; + title?: string; + selectedIndex?: number; +} + +export const CompletionMenu: React.FC = ({ + items, + onSelect, + onClose, + title, + selectedIndex = 0, +}) => { + const containerRef = useRef(null); + const [selected, setSelected] = useState(selectedIndex); + // Mount state to drive a simple Tailwind transition (replaces CSS keyframes) + const [mounted, setMounted] = useState(false); + + useEffect(() => setSelected(selectedIndex), [selectedIndex]); + useEffect(() => setMounted(true), []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelected((prev) => Math.min(prev + 1, items.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setSelected((prev) => Math.max(prev - 1, 0)); + break; + case 'Enter': + event.preventDefault(); + if (items[selected]) { + onSelect(items[selected]); + } + break; + case 'Escape': + event.preventDefault(); + onClose(); + break; + default: + break; + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [items, selected, onSelect, onClose]); + + useEffect(() => { + const selectedEl = containerRef.current?.querySelector( + `[data-index="${selected}"]`, + ); + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'nearest' }); + } + }, [selected]); + + if (!items.length) { + return null; + } + + return ( +
+ {/* Optional top spacer for visual separation from the input */} +
+
+ {title && ( +
+ {title} +
+ )} + {items.map((item, index) => { + const isActive = index === selected; + return ( +
onSelect(item)} + onMouseEnter={() => setSelected(index)} + className={[ + // Semantic + 'completion-menu-item', + // Hit area + 'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]', + 'p-[var(--app-list-item-padding)]', + // Active background + isActive ? 'bg-[var(--app-list-active-background)]' : '', + ].join(' ')} + > +
+ {item.icon && ( + + {item.icon} + + )} + + {item.label} + + {item.description && ( + + {item.description} + + )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx new file mode 100644 index 00000000..081352b8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +export const EmptyState: React.FC = () => { + // Generate icon URL using the utility function + const iconUri = generateIconUrl('icon.png'); + + return ( +
+
+ {/* Qwen Logo */} +
+ Qwen Logo +
+
+ What to do first? Ask about this codebase or we can start writing + code. +
+
+
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx new file mode 100644 index 00000000..356ffaf4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * FileLink component - Clickable file path links + * Supports clicking to open files and jump to specified line and column numbers + */ + +import type React from 'react'; +import { useVSCode } from '../../hooks/useVSCode.js'; +// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes + +/** + * Props for FileLink + */ +interface FileLinkProps { + /** File path */ + path: string; + /** Optional line number (starting from 1) */ + line?: number | null; + /** Optional column number (starting from 1) */ + column?: number | null; + /** Whether to show full path, default false (show filename only) */ + showFullPath?: boolean; + /** Optional custom class name */ + className?: string; + /** Whether to disable click behavior (use when parent element handles clicks) */ + disableClick?: boolean; +} + +/** + * Extract filename from full path + * @param path File path + * @returns Filename + */ +function getFileName(path: string): string { + const segments = path.split(/[/\\]/); + return segments[segments.length - 1] || path; +} + +/** + * FileLink component - Clickable file link + * + * Features: + * - Click to open file + * - Support line and column number navigation + * - Hover to show full path + * - Optional display mode (full path vs filename only) + * + * @example + * ```tsx + * + * + * ``` + */ +export const FileLink: React.FC = ({ + path, + line, + column, + showFullPath = false, + className = '', + disableClick = false, +}) => { + const vscode = useVSCode(); + + /** + * Handle click event - Send message to VSCode to open file + */ + const handleClick = (e: React.MouseEvent) => { + // Always prevent default behavior (prevent tag # navigation) + e.preventDefault(); + + if (disableClick) { + // If click is disabled, return directly without stopping propagation + // This allows parent elements to handle click events + return; + } + + // If click is enabled, stop event propagation + e.stopPropagation(); + + // Build full path including line and column numbers + let fullPath = path; + if (line !== null && line !== undefined) { + fullPath += `:${line}`; + if (column !== null && column !== undefined) { + fullPath += `:${column}`; + } + } + + console.log('[FileLink] Opening file:', fullPath); + + vscode.postMessage({ + type: 'openFile', + data: { path: fullPath }, + }); + }; + + // Build display text + const displayPath = showFullPath ? path : getFileName(path); + + // Build hover tooltip (always show full path) + const fullDisplayText = + line !== null && line !== undefined + ? column !== null && column !== undefined + ? `${path}:${line}:${column}` + : `${path}:${line}` + : path; + + return ( + + {displayPath} + {line !== null && line !== undefined && ( + + :{line} + {column !== null && column !== undefined && <>:{column}} + + )} + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx new file mode 100644 index 00000000..fe86ea99 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -0,0 +1,293 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + HideContextIcon, + ThinkingIcon, + SlashCommandIcon, + LinkIcon, + ArrowUpIcon, + StopIcon, +} from '../icons/index.js'; +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'; + +interface InputFormProps { + inputText: string; + // Note: RefObject carries nullability in its `current` property, so the + // generic should be `HTMLDivElement` (not `HTMLDivElement | null`). + inputFieldRef: React.RefObject; + isStreaming: boolean; + isWaitingForResponse: boolean; + isComposing: boolean; + editMode: ApprovalModeValue; + thinkingEnabled: boolean; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + // Whether to auto-load the active editor selection/path into context + skipAutoActiveContext: boolean; + onInputChange: (text: string) => void; + onCompositionStart: () => void; + onCompositionEnd: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + onToggleEditMode: () => void; + onToggleThinking: () => void; + onFocusActiveEditor: () => void; + onToggleSkipAutoActiveContext: () => void; + onShowCommandMenu: () => void; + onAttachContext: () => void; + completionIsOpen: boolean; + completionItems?: CompletionItem[]; + onCompletionSelect?: (item: CompletionItem) => void; + onCompletionClose?: () => void; +} + +// Get edit mode display info using helper function +const getEditModeInfo = (editMode: ApprovalModeValue) => { + const info = getApprovalModeInfoFromString(editMode); + + // Map icon types to actual icons + let icon = null; + switch (info.iconType) { + case 'edit': + icon = ; + break; + case 'auto': + icon = ; + break; + case 'plan': + icon = ; + break; + case 'yolo': + icon = ; + break; + default: + icon = null; + break; + } + + return { + text: info.label, + title: info.title, + icon, + }; +}; + +export const InputForm: React.FC = ({ + inputText, + inputFieldRef, + isStreaming, + isWaitingForResponse, + isComposing, + editMode, + thinkingEnabled, + activeFileName, + activeSelection, + skipAutoActiveContext, + onInputChange, + onCompositionStart, + onCompositionEnd, + onKeyDown, + onSubmit, + onCancel, + onToggleEditMode, + onToggleThinking, + onToggleSkipAutoActiveContext, + onShowCommandMenu, + onAttachContext, + completionIsOpen, + completionItems, + onCompletionSelect, + onCompletionClose, +}) => { + const editModeInfo = getEditModeInfo(editMode); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // ESC should cancel the current interaction (stop generation) + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + return; + } + // If composing (Chinese IME input), don't process Enter key + if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + // If CompletionMenu is open, let it handle Enter key + if (completionIsOpen) { + return; + } + e.preventDefault(); + onSubmit(e); + } + onKeyDown(e); + }; + + // Selection label like "6 lines selected"; no line numbers + const selectedLinesCount = activeSelection + ? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1) + : 0; + const selectedLinesText = + selectedLinesCount > 0 + ? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected` + : ''; + + return ( +
+
+
+ {/* Inner background layer */} +
+ + {/* Banner area */} +
+ +
+ {completionIsOpen && + completionItems && + completionItems.length > 0 && + onCompletionSelect && + onCompletionClose && ( + + )} + +
into contentEditable (so :empty no longer matches) + data-empty={inputText.trim().length === 0 ? 'true' : 'false'} + onInput={(e) => { + const target = e.target as HTMLDivElement; + onInputChange(target.textContent || ''); + }} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} + onKeyDown={handleKeyDown} + suppressContentEditableWarning + /> +
+ +
+ {/* Edit mode button */} + + + {/* Active file indicator */} + {activeFileName && ( + + )} + + {/* Spacer */} +
+ + {/* Thinking button */} + + + {/* Command button */} + + + {/* Attach button */} + + + {/* Send/Stop button */} + {isStreaming || isWaitingForResponse ? ( + + ) : ( + + )} +
+ +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx new file mode 100644 index 00000000..ab7f6d51 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { groupSessionsByDate } from '../../utils/sessionGrouping.js'; +import { getTimeAgo } from '../../utils/timeUtils.js'; +import { SearchIcon } from '../icons/index.js'; + +interface SessionSelectorProps { + visible: boolean; + sessions: Array>; + currentSessionId: string | null; + searchQuery: string; + onSearchChange: (query: string) => void; + onSelectSession: (sessionId: string) => void; + onClose: () => void; + hasMore?: boolean; + isLoading?: boolean; + onLoadMore?: () => void; +} + +/** + * Session selector component + * Display session list and support search and selection + */ +export const SessionSelector: React.FC = ({ + visible, + sessions, + currentSessionId, + searchQuery, + onSearchChange, + onSelectSession, + onClose, + hasMore = false, + isLoading = false, + onLoadMore, +}) => { + if (!visible) { + return null; + } + + const hasNoSessions = sessions.length === 0; + + return ( + <> +
+
e.stopPropagation()} + > + {/* Search Box */} +
+ + onSearchChange(e.target.value)} + /> +
+ + {/* Session List with Grouping */} +
{ + const el = e.currentTarget; + const distanceToBottom = + el.scrollHeight - (el.scrollTop + el.clientHeight); + if (distanceToBottom < 48 && hasMore && !isLoading) { + onLoadMore?.(); + } + }} + > + {hasNoSessions ? ( +
+ {searchQuery ? 'No matching sessions' : 'No sessions available'} +
+ ) : ( + groupSessionsByDate(sessions).map((group) => ( + +
+ {group.label} +
+
+ {group.sessions.map((session) => { + const sessionId = + (session.id as string) || + (session.sessionId as string) || + ''; + const title = + (session.title as string) || + (session.name as string) || + 'Untitled'; + const lastUpdated = + (session.lastUpdated as string) || + (session.startTime as string) || + ''; + const isActive = sessionId === currentSessionId; + + return ( + + ); + })} +
+
+ )) + )} + {hasMore && ( +
+ {isLoading ? 'Loading…' : ''} +
+ )} +
+
+ + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css new file mode 100644 index 00000000..56946662 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * AssistantMessage Component Styles + * Pseudo-elements (::before) for bullet points and (::after) for timeline connectors + */ + +/* Bullet point indicator using ::before pseudo-element */ +.assistant-message-container.assistant-message-default::before, +.assistant-message-container.assistant-message-success::before, +.assistant-message-container.assistant-message-error::before, +.assistant-message-container.assistant-message-warning::before, +.assistant-message-container.assistant-message-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + z-index: 1; +} + +/* Default state - secondary foreground color */ +.assistant-message-container.assistant-message-default::before { + color: var(--app-secondary-foreground); +} + +/* Success state - green bullet (maps to .ge) */ +.assistant-message-container.assistant-message-success::before { + color: #74c991; +} + +/* Error state - red bullet (maps to .be) */ +.assistant-message-container.assistant-message-error::before { + color: #c74e39; +} + +/* Warning state - yellow/orange bullet (maps to .ue) */ +.assistant-message-container.assistant-message-warning::before { + color: #e1c08d; +} + +/* Loading state - static bullet (maps to .he) */ +.assistant-message-container.assistant-message-loading::before { + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); +} + +.assistant-message-container.assistant-message-loading::after { + display: none +} 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 new file mode 100644 index 00000000..ed8badcc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from '../MessageContent.js'; +import './AssistantMessage.css'; + +interface AssistantMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; + status?: 'default' | 'success' | 'error' | 'warning' | 'loading'; + // When true, render without the left status bullet (no ::before dot) + hideStatusIcon?: boolean; +} + +/** + * AssistantMessage component - renders AI responses with Qwen Code styling + * Supports different states: default, success, error, warning, loading + */ +export const AssistantMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, + status = 'default', + hideStatusIcon = false, +}) => { + // Empty content not rendered directly, avoid poor visual experience from only showing ::before dot + if (!content || content.trim().length === 0) { + return null; + } + + // Map status to CSS class (only for ::before pseudo-element) + const getStatusClass = () => { + if (hideStatusIcon) { + return ''; + } + switch (status) { + case 'success': + return 'assistant-message-success'; + case 'error': + return 'assistant-message-error'; + case 'warning': + return 'assistant-message-warning'; + case 'loading': + return 'assistant-message-loading'; + default: + return 'assistant-message-default'; + } + }; + + return ( +
+ +
+ +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css new file mode 100644 index 00000000..37a3485a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Styles for MarkdownRenderer component + */ + +.markdown-content { + /* Base styles for markdown content */ + line-height: 1.6; + color: var(--app-primary-foreground); +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.markdown-content h1 { + font-size: 1.75em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h2 { + font-size: 1.5em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h3 { + font-size: 1.25em; +} + +.markdown-content h4 { + font-size: 1.1em; +} + +.markdown-content h5, +.markdown-content h6 { + font-size: 1em; +} + +.markdown-content p { + margin-top: 0; + /* margin-bottom: 1em; */ +} + +.markdown-content ul, +.markdown-content ol { + margin-top: 1em; + margin-bottom: 1em; + padding-left: 2em; +} + +/* Ensure list markers are visible even with global CSS resets */ +.markdown-content ul { + list-style-type: disc; + list-style-position: outside; +} + +.markdown-content ol { + list-style-type: decimal; + list-style-position: outside; +} + +/* Nested list styles */ +.markdown-content ul ul { + list-style-type: circle; +} + +.markdown-content ul ul ul { + list-style-type: square; +} + +.markdown-content ol ol { + list-style-type: lower-alpha; +} + +.markdown-content ol ol ol { + list-style-type: lower-roman; +} + +/* Style the marker explicitly so themes don't hide it */ +.markdown-content li::marker { + color: var(--app-secondary-foreground); +} + +.markdown-content li { + margin-bottom: 0.25em; +} + +.markdown-content li > p { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.markdown-content blockquote { + margin: 0 0 1em; + padding: 0 1em; + border-left: 0.25em solid var(--app-primary-border-color); + color: var(--app-secondary-foreground); +} + +.markdown-content a { + color: var(--app-link-foreground, #007acc); + text-decoration: none; +} + +.markdown-content a:hover { + color: var(--app-link-active-foreground, #005a9e); + text-decoration: underline; +} + +.markdown-content code { + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + padding: 0.2em 0.4em; + white-space: pre-wrap; /* Support automatic line wrapping */ + word-break: break-word; /* Break words when necessary */ +} + +.markdown-content pre { + margin: 1em 0; + padding: 1em; + overflow-x: auto; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + line-height: 1.5; +} + +.markdown-content pre code { + background: none; + border: none; + padding: 0; + white-space: pre-wrap; /* Support automatic line wrapping */ + word-break: break-word; /* Break words when necessary */ +} + +.markdown-content .file-path-link { + background: transparent; + border: none; + padding: 0; + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.95em; + color: var(--app-link-foreground, #007acc); + text-decoration: underline; + cursor: pointer; + transition: color 0.1s ease; +} + +.markdown-content .file-path-link:hover { + color: var(--app-link-active-foreground, #005a9e); +} + +.markdown-content hr { + border: none; + border-top: 1px solid var(--app-primary-border-color); + margin: 1.5em 0; +} + +.markdown-content img { + max-width: 100%; + height: auto; +} + +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; +} + +.markdown-content th, +.markdown-content td { + padding: 0.5em 1em; + border: 1px solid var(--app-primary-border-color); + text-align: left; +} + +.markdown-content th { + background-color: var(--app-secondary-background); + font-weight: 600; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx new file mode 100644 index 00000000..11246420 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx @@ -0,0 +1,392 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths + */ + +import type React from 'react'; +import MarkdownIt from 'markdown-it'; +import type { Options as MarkdownItOptions } from 'markdown-it'; +import './MarkdownRenderer.css'; + +interface MarkdownRendererProps { + content: string; + onFileClick?: (filePath: string) => void; + /** When false, do not convert file paths into clickable links. Default: true */ + enableFileLinks?: boolean; +} + +/** + * Regular expressions for parsing content + */ +// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts +const FILE_PATH_REGEX = + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi; +// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7 +const FILE_PATH_WITH_LINES_REGEX = + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; + +/** + * MarkdownRenderer component - renders markdown content with enhanced features + */ +export const MarkdownRenderer: React.FC = ({ + content, + onFileClick, + enableFileLinks = true, +}) => { + /** + * Initialize markdown-it with plugins + */ + const getMarkdownInstance = (): MarkdownIt => { + // Create markdown-it instance with options + const md = new MarkdownIt({ + html: false, // Disable HTML for security + xhtmlOut: false, + breaks: true, + linkify: true, + typographer: true, + } as MarkdownItOptions); + + return md; + }; + + /** + * Render markdown content to HTML + */ + const renderMarkdown = (): string => { + try { + const md = getMarkdownInstance(); + + // Process the markdown content + let html = md.render(content); + + // Post-process to add file path click handlers unless disabled + if (enableFileLinks) { + html = processFilePaths(html); + } + + return html; + } catch (error) { + console.error('Error rendering markdown:', error); + // Fallback to plain text if markdown rendering fails + return escapeHtml(content); + } + }; + + /** + * Escape HTML characters for security + */ + const escapeHtml = (unsafe: string): string => + unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + /** + * Process file paths in HTML to make them clickable + */ + const processFilePaths = (html: string): string => { + // If DOM is not available, bail out to avoid breaking SSR + if (typeof document === 'undefined') { + return html; + } + + // Build non-global variants to avoid .test() statefulness + const FILE_PATH_NO_G = new RegExp( + FILE_PATH_REGEX.source, + FILE_PATH_REGEX.flags.replace('g', ''), + ); + const FILE_PATH_WITH_LINES_NO_G = new RegExp( + FILE_PATH_WITH_LINES_REGEX.source, + FILE_PATH_WITH_LINES_REGEX.flags.replace('g', ''), + ); + // Match a bare file name like README.md (no leading slash) + const BARE_FILE_REGEX = + /[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)/i; + + // Parse HTML into a DOM tree so we don't replace inside attributes + const container = document.createElement('div'); + container.innerHTML = html; + + const union = new RegExp( + `${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`, + 'gi', + ); + + // Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line) + const normalizePathAndLine = ( + raw: string, + ): { displayText: string; dataPath: string } => { + const displayText = raw; + let base = raw; + // Extract hash fragment like #12, #L12 or #12-34 and keep only the first number + const hashIndex = raw.indexOf('#'); + if (hashIndex >= 0) { + const frag = raw.slice(hashIndex + 1); + // Accept L12, 12 or 12-34 + const m = frag.match(/^L?(\d+)(?:-\d+)?$/i); + if (m) { + const line = parseInt(m[1], 10); + base = raw.slice(0, hashIndex); + return { displayText, dataPath: `${base}:${line}` }; + } + } + return { displayText, dataPath: base }; + }; + + const makeLink = (text: string) => { + const link = document.createElement('a'); + // Pass base path (with optional :line) to the handler; keep the full text as label + const { dataPath } = normalizePathAndLine(text); + link.className = 'file-path-link'; + link.textContent = text; + link.setAttribute('href', '#'); + link.setAttribute('title', `Open ${text}`); + // Carry file path via data attribute; click handled by event delegation + link.setAttribute('data-file-path', dataPath); + return link; + }; + + const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => { + const href = a.getAttribute('href') || ''; + const text = (a.textContent || '').trim(); + + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. + const isCodeReference = (str: string): boolean => { + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + + // If linkify turned a bare filename (e.g. README.md) into http://, convert it back + const httpMatch = href.match(/^https?:\/\/(.+)$/i); + if (httpMatch) { + try { + const url = new URL(href); + const host = url.hostname || ''; + const pathname = url.pathname || ''; + const noPath = pathname === '' || pathname === '/'; + + // Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md) + if ( + noPath && + BARE_FILE_REGEX.test(text) && + host.toLowerCase() === text.toLowerCase() + ) { + const { dataPath } = normalizePathAndLine(text); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text}`); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Case 2: host itself looks like a filename (rare but happens), use it + if (noPath && BARE_FILE_REGEX.test(host)) { + const { dataPath } = normalizePathAndLine(host); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || host}`); + a.setAttribute('data-file-path', dataPath); + return; + } + } catch { + // fall through; unparseable URL + } + } + + // Ignore other external protocols + if (/^(https?|mailto|ftp|data):/i.test(href)) { + return; + } + + const candidate = href || text; + + // Skip if it looks like a code reference + if (isCodeReference(candidate)) { + return; + } + + if ( + FILE_PATH_WITH_LINES_NO_G.test(candidate) || + FILE_PATH_NO_G.test(candidate) + ) { + const { dataPath } = normalizePathAndLine(candidate); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || href}`); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Bare file name or relative path (e.g. README.md or docs/README.md) + if (BARE_FILE_REGEX.test(candidate)) { + const { dataPath } = normalizePathAndLine(candidate); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || href}`); + a.setAttribute('data-file-path', dataPath); + } + }; + + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. + const isCodeReference = (str: string): boolean => { + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + + const walk = (node: Node) => { + // Do not transform inside existing anchors + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (el.tagName.toLowerCase() === 'a') { + upgradeAnchorIfFilePath(el as HTMLAnchorElement); + return; // Don't descend into + } + // Avoid transforming inside code/pre blocks + const tag = el.tagName.toLowerCase(); + if (tag === 'code' || tag === 'pre') { + return; + } + } + + for (let child = node.firstChild; child; ) { + const next = child.nextSibling; // child may be replaced + if (child.nodeType === Node.TEXT_NODE) { + const text = child.nodeValue || ''; + union.lastIndex = 0; + const hasMatch = union.test(text); + union.lastIndex = 0; + if (hasMatch) { + const frag = document.createDocumentFragment(); + let lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = union.exec(text))) { + const matchText = m[0]; + const idx = m.index; + + // Skip if it looks like a code reference + if (isCodeReference(matchText)) { + // Just add the text as-is without creating a link + if (idx > lastIndex) { + frag.appendChild( + document.createTextNode(text.slice(lastIndex, idx)), + ); + } + frag.appendChild(document.createTextNode(matchText)); + lastIndex = idx + matchText.length; + continue; + } + + if (idx > lastIndex) { + frag.appendChild( + document.createTextNode(text.slice(lastIndex, idx)), + ); + } + frag.appendChild(makeLink(matchText)); + lastIndex = idx + matchText.length; + } + if (lastIndex < text.length) { + frag.appendChild(document.createTextNode(text.slice(lastIndex))); + } + node.replaceChild(frag, child); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + walk(child); + } + child = next; + } + }; + + walk(container); + return container.innerHTML; + }; + + // Event delegation: intercept clicks on generated file-path links + const handleContainerClick = ( + e: React.MouseEvent, + ) => { + // If file links disabled, do nothing + if (!enableFileLinks) { + return; + } + const target = e.target as HTMLElement | null; + if (!target) { + return; + } + + // Find nearest anchor with our marker class + const anchor = (target.closest && + target.closest('a.file-path-link')) as HTMLAnchorElement | null; + if (anchor) { + const filePath = anchor.getAttribute('data-file-path'); + if (!filePath) { + return; + } + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(filePath); + return; + } + + // Fallback: intercept "http://README.md" style links that slipped through + const anyAnchor = (target.closest && + target.closest('a')) as HTMLAnchorElement | null; + if (!anyAnchor) { + return; + } + + const href = anyAnchor.getAttribute('href') || ''; + if (!/^https?:\/\//i.test(href)) { + return; + } + try { + const url = new URL(href); + const host = url.hostname || ''; + const path = url.pathname || ''; + const noPath = path === '' || path === '/'; + + // Basic bare filename heuristic on the host part (e.g. README.md) + if (noPath && /\.[a-z0-9]+$/i.test(host)) { + // Prefer the readable text content if it looks like a file + const text = (anyAnchor.textContent || '').trim(); + const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host; + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(candidate); + } + } catch { + // ignore + } + }; + + return ( +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx new file mode 100644 index 00000000..3381e90d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js'; + +interface MessageContentProps { + content: string; + onFileClick?: (filePath: string) => void; + enableFileLinks?: boolean; +} + +export const MessageContent: React.FC = ({ + content, + onFileClick, + enableFileLinks, +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx new file mode 100644 index 00000000..1f92e1f4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from './MessageContent.js'; + +interface ThinkingMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; +} + +export const ThinkingMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, +}) => ( +
+
+ + + + + + +
+
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx new file mode 100644 index 00000000..1014736a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from './MessageContent.js'; + +interface FileContext { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; +} + +interface UserMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; + fileContext?: FileContext; +} + +export const UserMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, + fileContext, +}) => { + // Generate display text for file context + const getFileContextDisplay = () => { + if (!fileContext) { + return null; + } + const { fileName, startLine, endLine } = fileContext; + if (startLine && endLine) { + return startLine === endLine + ? `${fileName}#${startLine}` + : `${fileName}#${startLine}-${endLine}`; + } + return fileName; + }; + + const fileContextDisplay = getFileContextDisplay(); + + return ( +
+
+ {/* For user messages, do NOT convert filenames to clickable links */} + +
+ + {/* File context indicator */} + {fileContextDisplay && ( +
+
fileContext && onFileClick?.(fileContext.filePath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + fileContext && onFileClick?.(fileContext.filePath); + } + }} + > +
+ {fileContextDisplay} +
+
+
+ )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx new file mode 100644 index 00000000..0c0e4c8d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +interface InterruptedMessageProps { + text?: string; +} + +// A lightweight status line similar to WaitingMessage but without the left status icon. +export const InterruptedMessage: React.FC = ({ + text = 'Interrupted', +}) => ( +
+
+ {text} +
+
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css new file mode 100644 index 00000000..9a109a08 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +@import url('../Assistant/AssistantMessage.css'); + +/* Subtle shimmering highlight across the loading text */ +@keyframes waitingMessageShimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.loading-text-shimmer { + /* Use the theme foreground as the base color, with a moving light band */ + background-image: linear-gradient( + 90deg, + var(--app-secondary-foreground) 0%, + var(--app-secondary-foreground) 40%, + rgba(255, 255, 255, 0.95) 50%, + var(--app-secondary-foreground) 60%, + var(--app-secondary-foreground) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; /* text color comes from the gradient */ + animation: waitingMessageShimmer 1.6s linear infinite; +} + +.interrupted-item::after { + display: none; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx new file mode 100644 index 00000000..68aceac8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import './WaitingMessage.css'; +import { WITTY_LOADING_PHRASES } from '../../../../constants/loadingMessages.js'; + +interface WaitingMessageProps { + loadingMessage: string; +} + +// Rotate message every few seconds while waiting +const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request + +export const WaitingMessage: React.FC = ({ + loadingMessage, +}) => { + // Build a phrase list that starts with the provided message (if any), then witty fallbacks + const phrases = useMemo(() => { + const set = new Set(); + const list: string[] = []; + if (loadingMessage && loadingMessage.trim()) { + list.push(loadingMessage); + set.add(loadingMessage); + } + for (const p of WITTY_LOADING_PHRASES) { + if (!set.has(p)) { + list.push(p); + } + } + return list; + }, [loadingMessage]); + + const [index, setIndex] = useState(0); + + // Reset to the first phrase whenever the incoming message changes + useEffect(() => { + setIndex(0); + }, [phrases]); + + // Periodically rotate to a different phrase + useEffect(() => { + if (phrases.length <= 1) { + return; + } + const id = setInterval(() => { + setIndex((prev) => { + // pick a different random index to avoid immediate repeats + let next = Math.floor(Math.random() * phrases.length); + if (phrases.length > 1) { + let guard = 0; + while (next === prev && guard < 5) { + next = Math.floor(Math.random() * phrases.length); + guard++; + } + } + return next; + }); + }, ROTATE_INTERVAL_MS); + return () => clearInterval(id); + }, [phrases]); + + return ( +
+ {/* Use the same left status icon (pseudo-element) style as assistant-message-container */} +
+ + {phrases[index]} + +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx new file mode 100644 index 00000000..2ec06e87 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { UserMessage } from './UserMessage.js'; +export { AssistantMessage } from './Assistant/AssistantMessage.js'; +export { ThinkingMessage } from './ThinkingMessage.js'; +export { WaitingMessage } from './Waiting/WaitingMessage.js'; +export { InterruptedMessage } from './Waiting/InterruptedMessage.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css new file mode 100644 index 00000000..bbd8080d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call styles - Enhanced styling with semantic class names + */ + +/* Root container for execute tool call output */ +.bash-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +/* Content wrapper inside the card */ +.bash-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +/* Individual input/output row */ +.bash-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +/* First row has no top border */ +.bash-toolcall-row:first-child { + border-top: none; +} + +/* Row label (IN/OUT/ERROR) */ +.bash-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Row content area */ +.bash-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +/* Truncated content styling */ +.bash-toolcall-row-content:not(.bash-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +/* Preformatted content */ +.bash-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Code content */ +.bash-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Output content with subtle styling */ +.bash-toolcall-output-subtle { + background-color: var(--app-code-background); + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +/* Error content styling */ +.bash-toolcall-error-content { + color: #c74e39; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx new file mode 100644 index 00000000..acd1fe26 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call component - specialized for command execution operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { ToolCallContainer } from '../shared/LayoutComponents.js'; +import { safeTitle, groupContent } from '../shared/utils.js'; +import { useVSCode } from '../../../../hooks/useVSCode.js'; +import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js'; +import './Bash.css'; + +/** + * Specialized component for Execute/Bash tool calls + * Shows: Bash bullet + description + IN/OUT card + */ +export const ExecuteToolCall: React.FC = ({ toolCall }) => { + const { title, content, rawInput, toolCallId } = toolCall; + const commandText = safeTitle(title); + const vscode = useVSCode(); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Extract command from rawInput if available + let inputCommand = commandText; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { command?: string }; + inputCommand = inputObj.command || commandText; + } else if (typeof rawInput === 'string') { + inputCommand = rawInput; + } + + // Handle click on IN section + const handleInClick = () => { + createAndOpenTempFile( + vscode.postMessage, + inputCommand, + 'bash-input', + '.sh', + ); + }; + + // Handle click on OUT section + const handleOutClick = () => { + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt'); + } + }; + + // Map tool status to container status for proper bullet coloring + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = + errors.length > 0 + ? 'error' + : toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + + // Error case + if (errors.length > 0) { + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Error card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* ERROR row */} +
+
Error
+
+
+                  {errors.join('\n')}
+                
+
+
+
+
+
+ ); + } + + // Success with output + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const truncatedOutput = + output.length > 500 ? output.substring(0, 500) + '...' : output; + + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Output card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* OUT row */} +
+
OUT
+
+
+
{truncatedOutput}
+
+
+
+
+
+
+ ); + } + + // Success without output: show command with branch connector + return ( + +
+ + {commandText} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx new file mode 100644 index 00000000..a8485316 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit tool call component - specialized for file editing operations + */ + +import { useMemo } from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
{children}
+ )} +
+
+); + +/** + * Calculate diff summary (added/removed lines) + */ +const getDiffSummary = ( + oldText: string | null | undefined, + newText: string | undefined, +): string => { + const oldLines = oldText ? oldText.split('\n').length : 0; + const newLines = newText ? newText.split('\n').length : 0; + const diff = newLines - oldLines; + + if (diff > 0) { + return `+${diff} lines`; + } else if (diff < 0) { + return `${diff} lines`; + } else { + return 'Modified'; + } +}; + +/** + * Specialized component for Edit tool calls + * Optimized for displaying file editing operations with diffs + */ +export const EditToolCall: React.FC = ({ toolCall }) => { + const { content, locations, toolCallId } = toolCall; + + // Group content by type; memoize to avoid new array identities on every render + const { errors, diffs } = useMemo(() => groupContent(content), [content]); + + // Failed case: show explicit failed message and render inline diffs + if (toolCall.status === 'failed') { + const firstDiff = diffs[0]; + const path = firstDiff?.path || locations?.[0]?.path || ''; + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( +
+
+
+
+ + Edit + + {path && ( + + )} +
+
+ {/* Failed state text (replace summary) */} +
+ edit failed +
+
+
+ ); + } + + // Error case: show error + if (errors.length > 0) { + const path = diffs[0]?.path || locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {errors.join('\n')} + + ); + } + + // Success case with diff: show minimal inline preview; clicking the title opens VS Code diff + if (diffs.length > 0) { + const firstDiff = diffs[0]; + const path = firstDiff.path || (locations && locations[0]?.path) || ''; + const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText); + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( +
+
+
+
+ {/* Align the inline Edit label styling with shared toolcall label: larger + bold */} + + Edit + + {path && ( + + )} +
+
+
+ + {summary} +
+
+
+ ); + } + + // Success case without diff: show file in compact format + if (locations && locations.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + } + > +
+ + +
+
+ ); + } + + // No output, don't show anything + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css new file mode 100644 index 00000000..97a561c5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call styles - Enhanced styling with semantic class names + */ + +/* Root container for execute tool call output */ +.execute-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +/* Content wrapper inside the card */ +.execute-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +/* Individual input/output row */ +.execute-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +/* First row has no top border */ +.execute-toolcall-row:first-child { + border-top: none; +} + +/* Row label (IN/OUT/ERROR) */ +.execute-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Row content area */ +.execute-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +/* Truncated content styling */ +.execute-toolcall-row-content:not(.execute-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +/* Preformatted content */ +.execute-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Code content */ +.execute-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Output content with subtle styling */ +.execute-toolcall-output-subtle { + background-color: var(--app-code-background); + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +/* Error content styling */ +.execute-toolcall-error-content { + color: #c74e39; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx new file mode 100644 index 00000000..1a2f2754 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call component - specialized for command execution operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { safeTitle, groupContent } from '../shared/utils.js'; +import './Execute.css'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
{children}
+ )} +
+
+); + +/** + * Specialized component for Execute tool calls + * Shows: Execute bullet + description + IN/OUT card + */ +export const ExecuteToolCall: React.FC = ({ toolCall }) => { + const { title, content, rawInput, toolCallId } = toolCall; + const commandText = safeTitle( + (rawInput as Record)?.description || title, + ); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Extract command from rawInput if available + let inputCommand = commandText; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as Record; + inputCommand = (inputObj.command as string | undefined) || commandText; + } else if (typeof rawInput === 'string') { + inputCommand = rawInput; + } + + // Map tool status to container status for proper bullet coloring + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = + errors.length > 0 || toolCall.status === 'failed' + ? 'error' + : toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + + // Error case + if (errors.length > 0) { + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Error card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* ERROR row */} +
+
Error
+
+
+                  {errors.join('\n')}
+                
+
+
+
+
+
+ ); + } + + // Success with output + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const truncatedOutput = + output.length > 500 ? output.substring(0, 500) + '...' : output; + + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Output card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* OUT row */} +
+
OUT
+
+
+
{truncatedOutput}
+
+
+
+
+
+
+ ); + } + + // Success without output: show command with branch connector + return ( + +
+ + {commandText} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx new file mode 100644 index 00000000..50e88443 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Generic tool call component - handles all tool call types as fallback + */ + +import type React from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { + ToolCallContainer, + ToolCallCard, + ToolCallRow, + LocationsList, +} from './shared/LayoutComponents.js'; +import { safeTitle, groupContent } from './shared/utils.js'; + +/** + * Generic tool call component that can display any tool call type + * Used as fallback for unknown tool call kinds + * Minimal display: show description and outcome + */ +export const GenericToolCall: React.FC = ({ toolCall }) => { + const { kind, title, content, locations, toolCallId } = toolCall; + const operationText = safeTitle(title); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Error case: show operation + error in card layout + if (errors.length > 0) { + return ( + + +
{operationText}
+
+ +
{errors.join('\n')}
+
+
+ ); + } + + // Success with output: use card for long output, compact for short + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const isLong = output.length > 150; + + if (isLong) { + const truncatedOutput = + output.length > 300 ? output.substring(0, 300) + '...' : output; + + return ( + + +
{operationText}
+
+ +
+ {truncatedOutput} +
+
+
+ ); + } + + // Short output - compact format + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + {operationText || output} + + ); + } + + // Success with files: show operation + file list in compact format + if (locations && locations.length > 0) { + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + + + ); + } + + // No output - show just the operation + if (operationText) { + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + {operationText} + + ); + } + + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx new file mode 100644 index 00000000..fcd1576c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Read tool call component - specialized for file reading operations + */ + +import type React from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import { useVSCode } from '../../../../hooks/useVSCode.js'; +import { handleOpenDiff } from '../../../../utils/diffUtils.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +/** + * Specialized component for Read tool calls + * Optimized for displaying file reading operations + * Shows: Read filename (no content preview) + */ +export const ReadToolCall: React.FC = ({ toolCall }) => { + const { content, locations, toolCallId } = toolCall; + const vscode = useVSCode(); + + // Group content by type; memoize to avoid new array identities on every render + const { errors, diffs } = useMemo(() => groupContent(content), [content]); + + // Post a message to the extension host to open a VS Code diff tab + const handleOpenDiffInternal = useCallback( + ( + path: string | undefined, + oldText: string | null | undefined, + newText: string | undefined, + ) => { + handleOpenDiff(vscode, path, oldText, newText); + }, + [vscode], + ); + + // Auto-open diff when a read call returns diff content. + // Only trigger once per toolCallId so we don't spam as in-progress updates stream in. + useEffect(() => { + if (diffs.length > 0) { + const firstDiff = diffs[0]; + const path = firstDiff.path || (locations && locations[0]?.path) || ''; + + if ( + path && + firstDiff.oldText !== undefined && + firstDiff.newText !== undefined + ) { + const timer = setTimeout(() => { + handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText); + }, 100); + return () => timer && clearTimeout(timer); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toolCallId]); + + // Compute container status based on toolCall.status (pending/in_progress -> loading) + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = mapToolStatusToContainerStatus(toolCall.status); + + // Error case: show error + if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {errors.join('\n')} + + ); + } + + // Success case with diff: keep UI compact; VS Code diff is auto-opened above + if (diffs.length > 0) { + const path = diffs[0]?.path || locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {null} + + ); + } + + // Success case: show which file was read with filename in label + if (locations && locations.length > 0) { + const path = locations[0].path; + return ( + + ) : undefined + } + > + {null} + + ); + } + + // No file info, don't show + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx new file mode 100644 index 00000000..5a57c443 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Search tool call component - specialized for search operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import { + safeTitle, + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; + +/** + * Specialized component for Search tool calls + * Optimized for displaying search operations and results + * Shows query + result count or file list + */ +const InlineContainer: React.FC<{ + status: 'success' | 'error' | 'warning' | 'loading' | 'default'; + labelSuffix?: string; + children?: React.ReactNode; + isFirst?: boolean; + isLast?: boolean; +}> = ({ status, labelSuffix, children, isFirst, isLast }) => { + const beforeStatusClass = `toolcall-container toolcall-status-${status}`; + const lineCropTop = isFirst ? 'top-[24px]' : 'top-0'; + const lineCropBottom = isLast + ? 'bottom-auto h-[calc(100%-24px)]' + : 'bottom-0'; + return ( +
+ {/* timeline vertical line */} +
+
+
+ + Search + + {labelSuffix ? ( + + {labelSuffix} + + ) : null} +
+ {children ? ( +
+ {children} +
+ ) : null} +
+
+ ); +}; + +// Local card layout for multi-result or error display +const SearchCard: React.FC<{ + status: 'success' | 'error' | 'warning' | 'loading' | 'default'; + children: React.ReactNode; + isFirst?: boolean; + isLast?: boolean; +}> = ({ status, children, isFirst, isLast }) => { + const beforeStatusClass = + status === 'success' + ? 'before:text-qwen-success' + : status === 'error' + ? 'before:text-qwen-error' + : status === 'warning' + ? 'before:text-qwen-warning' + : 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow'; + const lineCropTop = isFirst ? 'top-[24px]' : 'top-0'; + const lineCropBottom = isLast + ? 'bottom-auto h-[calc(100%-24px)]' + : 'bottom-0'; + return ( +
+ {/* timeline vertical line */} +
+
+
{children}
+
+
+ ); +}; + +const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +const LocationsListLocal: React.FC<{ + locations: Array<{ path: string; line?: number | null }>; +}> = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); + +export const SearchToolCall: React.FC = ({ + toolCall, + isFirst, + isLast, +}) => { + const { title, content, locations } = toolCall; + const queryText = safeTitle(title); + + // Group content by type + const { errors, textOutputs } = groupContent(content); + + // Error case: show search query + error in card layout + if (errors.length > 0) { + return ( + + +
{queryText}
+
+ +
{errors.join('\n')}
+
+
+ ); + } + + // Success case with results: show search query + file list + if (locations && locations.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + // If multiple results, use card layout; otherwise use compact format + if (locations.length > 1) { + return ( + + +
{queryText}
+
+ + + +
+ ); + } + // Single result - compact format + return ( + + + + + ); + } + + // Show content text if available (e.g., "Listed 4 item(s).") + if (textOutputs.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + +
+ {textOutputs.map((text, index) => ( +
+ + {text} +
+ ))} +
+
+ ); + } + + // No results - show query only + if (queryText) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + {queryText} + + ); + } + + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx new file mode 100644 index 00000000..4c49b2cc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Think tool call component - specialized for thinking/reasoning operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + ToolCallContainer, + ToolCallCard, + ToolCallRow, +} from '../shared/LayoutComponents.js'; +import { groupContent } from '../shared/utils.js'; + +/** + * Specialized component for Think tool calls + * Optimized for displaying AI reasoning and thought processes + * Minimal display: just show the thoughts (no context) + */ +export const ThinkToolCall: React.FC = ({ toolCall }) => { + const { content } = toolCall; + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Error case (rare for thinking) + if (errors.length > 0) { + return ( + + {errors.join('\n')} + + ); + } + + // Show thoughts - use card for long content, compact for short + if (textOutputs.length > 0) { + const thoughts = textOutputs.join('\n\n'); + const isLong = thoughts.length > 200; + + if (isLong) { + const truncatedThoughts = + thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts; + + return ( + + +
+ {truncatedThoughts} +
+
+
+ ); + } + + // Short thoughts - compact format + const status = + toolCall.status === 'pending' || toolCall.status === 'in_progress' + ? 'loading' + : 'default'; + return ( + + {thoughts} + + ); + } + + // Empty thoughts + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx new file mode 100644 index 00000000..6cda54a2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Main ToolCall component - uses factory pattern to route to specialized components + * + * This file serves as the public API for tool call rendering. + * It re-exports the router and types from the toolcalls module. + */ + +import type React from 'react'; +import { ToolCallRouter } from './index.js'; + +// Re-export types from the toolcalls module for backward compatibility +export type { + ToolCallData, + BaseToolCallProps as ToolCallProps, +} from './shared/types.js'; + +// Re-export the content type for external use +export type { ToolCallContent } from './shared/types.js'; +export const ToolCall: React.FC<{ + toolCall: import('./shared/types.js').ToolCallData; + isFirst?: boolean; + isLast?: boolean; +}> = ({ toolCall, isFirst, isLast }) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx new file mode 100644 index 00000000..d17ed073 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +export interface CheckboxDisplayProps { + checked?: boolean; + indeterminate?: boolean; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + title?: string; +} + +/** + * Display-only checkbox styled via Tailwind classes. + * - Renders a custom-looking checkbox using appearance-none and pseudo-elements. + * - Supports indeterminate (middle) state using the DOM property and a data- attribute. + * - Intended for read-only display (disabled by default). + */ +export const CheckboxDisplay: React.FC = ({ + checked = false, + indeterminate = false, + disabled = true, + className = '', + style, + title, +}) => { + // Render as a span (not ) so we can draw a checkmark with CSS. + // Pseudo-elements do not reliably render on in Chromium (VS Code webviews), + // which caused the missing icon. This version is font-free and uses borders. + const showCheck = !!checked && !indeterminate; + const showInProgress = !!indeterminate; + + return ( + + {showCheck ? ( + + ) : null} + {showInProgress ? ( + + * + + ) : null} + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx new file mode 100644 index 00000000..70e38bd7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * UpdatedPlan tool call component - specialized for plan update operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; +import { groupContent, safeTitle } from '../shared/utils.js'; +import { CheckboxDisplay } from './CheckboxDisplay.js'; +import type { PlanEntry } from '../../../../../types/chatTypes.js'; + +type EntryStatus = 'pending' | 'in_progress' | 'completed'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +const mapToolStatusToBullet = ( + status: import('../shared/types.js').ToolCallStatus, +): 'success' | 'error' | 'warning' | 'loading' | 'default' => { + switch (status) { + case 'completed': + return 'success'; + case 'failed': + return 'error'; + case 'in_progress': + return 'warning'; + case 'pending': + return 'loading'; + default: + return 'default'; + } +}; + +// Parse plan entries with - [ ] / - [x] from text as much as possible +const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => { + const text = textOutputs.join('\n'); + const lines = text.split(/\r?\n/); + const entries: PlanEntry[] = []; + + // Accept [ ], [x]/[X] and in-progress markers [-] or [*] + const todoRe = /^(?:\s*(?:[-*]|\d+[.)])\s*)?\[( |x|X|-|\*)\]\s+(.*)$/; + for (const line of lines) { + const m = line.match(todoRe); + if (m) { + const mark = m[1]; + const title = m[2].trim(); + const status: EntryStatus = + mark === 'x' || mark === 'X' + ? 'completed' + : mark === '-' || mark === '*' + ? 'in_progress' + : 'pending'; + if (title) { + entries.push({ content: title, status }); + } + } + } + + // If no match is found, fall back to treating non-empty lines as pending items + if (entries.length === 0) { + for (const line of lines) { + const title = line.trim(); + if (title) { + entries.push({ content: title, status: 'pending' }); + } + } + } + + return entries; +}; + +/** + * Specialized component for UpdatedPlan tool calls + * Optimized for displaying plan update operations + */ +export const UpdatedPlanToolCall: React.FC = ({ + toolCall, +}) => { + const { content, status } = toolCall; + const { errors, textOutputs } = groupContent(content); + + // Error-first display + if (errors.length > 0) { + return ( + + {errors.join('\n')} + + ); + } + + const entries = parsePlanEntries(textOutputs); + + const label = safeTitle(toolCall.title) || 'Updated Plan'; + + return ( + +
    + {entries.map((entry, idx) => { + const isDone = entry.status === 'completed'; + const isIndeterminate = entry.status === 'in_progress'; + return ( +
  • + + +
    + {entry.content} +
    +
  • + ); + })} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx new file mode 100644 index 00000000..d0e6307b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Write tool call component - specialized for file writing operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { ToolCallContainer } from '../shared/LayoutComponents.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; + +/** + * Specialized component for Write tool calls + * Shows: Write filename + error message + content preview + */ +export const WriteToolCall: React.FC = ({ toolCall }) => { + const { content, locations, rawInput, toolCallId } = toolCall; + + // Group content by type + const { errors, textOutputs } = groupContent(content); + + // Extract filename from path + // const getFileName = (path: string): string => path.split('/').pop() || path; + + // Extract content to write from rawInput + let writeContent = ''; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { content?: string }; + writeContent = inputObj.content || ''; + } else if (typeof rawInput === 'string') { + writeContent = rawInput; + } + + // Error case: show filename + error message + content preview + if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + const errorMessage = errors.join('\n'); + + // Truncate content preview + const truncatedContent = + writeContent.length > 200 + ? writeContent.substring(0, 200) + '...' + : writeContent; + + return ( + + ) : undefined + } + > +
+ + {errorMessage} +
+ {truncatedContent && ( +
+
+              {truncatedContent}
+            
+
+ )} +
+ ); + } + + // Success case: show filename + line count + if (locations && locations.length > 0) { + const path = locations[0].path; + const lineCount = writeContent.split('\n').length; + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + ) : undefined + } + > +
+ + {lineCount} lines +
+
+ ); + } + + // Fallback: show generic success + if (textOutputs.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + {textOutputs.join('\n')} + + ); + } + + // No output, don't show anything + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx new file mode 100644 index 00000000..3f7a2bc0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Tool call component factory - routes to specialized components by kind + */ + +import type React from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { shouldShowToolCall } from './shared/utils.js'; +import { GenericToolCall } from './GenericToolCall.js'; +import { ReadToolCall } from './Read/ReadToolCall.js'; +import { WriteToolCall } from './Write/WriteToolCall.js'; +import { EditToolCall } from './Edit/EditToolCall.js'; +import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js'; +import { ExecuteToolCall } from './Execute/Execute.js'; +import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js'; +import { SearchToolCall } from './Search/SearchToolCall.js'; +import { ThinkToolCall } from './Think/ThinkToolCall.js'; + +/** + * Factory function that returns the appropriate tool call component based on kind + */ +export const getToolCallComponent = ( + kind: string, +): React.FC => { + const normalizedKind = kind.toLowerCase(); + + // Route to specialized components + switch (normalizedKind) { + case 'read': + return ReadToolCall; + + case 'write': + return WriteToolCall; + + case 'edit': + return EditToolCall; + + case 'execute': + return ExecuteToolCall; + + case 'bash': + case 'command': + return BashExecuteToolCall; + + case 'updated_plan': + case 'updatedplan': + case 'todo_write': + case 'update_todos': + case 'todowrite': + return UpdatedPlanToolCall; + + case 'search': + case 'grep': + case 'glob': + case 'find': + return SearchToolCall; + + case 'think': + case 'thinking': + return ThinkToolCall; + + default: + // Fallback to generic component + return GenericToolCall; + } +}; + +/** + * Main tool call component that routes to specialized implementations + */ +export const ToolCallRouter: React.FC< + BaseToolCallProps & { isFirst?: boolean; isLast?: boolean } +> = ({ toolCall, isFirst, isLast }) => { + // Check if we should show this tool call (hide internal ones) + if (!shouldShowToolCall(toolCall.kind)) { + return null; + } + + // Get the appropriate component for this kind + const Component = getToolCallComponent(toolCall.kind); + + // Render the specialized component + return ; +}; + +// Re-export types for convenience +export type { BaseToolCallProps, ToolCallData } from './shared/types.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css new file mode 100644 index 00000000..39846d77 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * LayoutComponents.css - Tool call layout styles with timeline support + */ + +/* ToolCallContainer with timeline support */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; + user-select: text; + align-items: flex-start; +} + +/* Default timeline connector line */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* Status-specific styles using ::before pseudo-element for bullet points */ +.toolcall-container.toolcall-status-default::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + z-index: 1; +} + +.toolcall-container.toolcall-status-success::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #74c991; + z-index: 1; +} + +.toolcall-container.toolcall-status-error::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #c74e39; + z-index: 1; +} + +.toolcall-container.toolcall-status-warning::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: #e1c08d; + z-index: 1; +} + +.toolcall-container.toolcall-status-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); + animation: toolcallPulse 1s linear infinite; + z-index: 1; +} + +/* Loading animation */ +@keyframes toolcallPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Content wrapper */ +.toolcall-content-wrapper { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +/* Legacy card styles */ +.toolcall-card { + grid-template-columns: auto 1fr; + gap: var(--spacing-medium); + background: var(--app-input-background); + border: 1px solid var(--app-input-border); + border-radius: var(--border-radius-medium); + padding: var(--spacing-large); + margin: var(--spacing-medium) 0; + align-items: start; + animation: fadeIn 0.2s ease-in; +} + +/* Legacy row styles */ +.toolcall-row { + grid-template-columns: 80px 1fr; + gap: var(--spacing-medium); + min-width: 0; +} + +.toolcall-row-label { + font-size: var(--font-size-xs); + color: var(--app-secondary-foreground); + font-weight: 500; + padding-top: 2px; +} + +.toolcall-row-content { + color: var(--app-primary-foreground); + min-width: 0; + word-break: break-word; +} + +/* Locations list */ +.toolcall-locations-list { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 100%; +} + +/* ToolCall header with loading indicator */ +.toolcall-header { + position: relative; +} + +.toolcall-header::before { + content: '\25cf'; + position: absolute; + left: -22px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + line-height: 1; + z-index: 1; + color: #e1c08d; + animation: toolcallHeaderPulse 1.5s ease-in-out infinite; +} + +/* Loading animation for toolcall header */ +@keyframes toolcallHeaderPulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* In-progress toolcall specific styles */ +.in-progress-toolcall .toolcall-content-wrapper { + display: flex; + flex-direction: column; + gap: 1; + min-width: 0; + max-width: 100%; +} + +.in-progress-toolcall .toolcall-header { + display: flex; + align-items: center; + gap: 2; + position: relative; + min-width: 0; +} + +.in-progress-toolcall .toolcall-content-text { + word-break: break-word; + white-space: pre-wrap; + width: 100%; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx new file mode 100644 index 00000000..89a0b14c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared layout components for tool call UI + */ + +import type React from 'react'; +import { FileLink } from '../../../layout/FileLink.js'; +import './LayoutComponents.css'; + +/** + * Props for ToolCallContainer + */ +export interface ToolCallContainerProps { + /** Operation label (e.g., "Read", "Write", "Search") */ + label: string; + /** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */ + status?: 'success' | 'error' | 'warning' | 'loading' | 'default'; + /** Main content to display */ + children: React.ReactNode; + /** Tool call ID for debugging */ + toolCallId?: string; + /** Optional trailing content rendered next to label (e.g., clickable filename) */ + labelSuffix?: React.ReactNode; + /** Optional custom class name */ + className?: string; +} + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +interface ToolCallCardProps { + icon: string; + children: React.ReactNode; +} + +/** + * Legacy card wrapper - kept for backward compatibility with complex layouts like diffs + */ +export const ToolCallCard: React.FC = ({ + icon: _icon, + children, +}) => ( +
+
{children}
+
+); + +interface ToolCallRowProps { + label: string; + children: React.ReactNode; +} + +/** + * A single row in the tool call grid (legacy - for complex layouts) + */ +export const ToolCallRow: React.FC = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +/** + * Props for StatusIndicator + */ +interface StatusIndicatorProps { + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + text: string; +} + +/** + * Get status color class + */ +const getStatusColorClass = ( + status: 'pending' | 'in_progress' | 'completed' | 'failed', +): string => { + switch (status) { + case 'pending': + return 'bg-[#ffc107]'; + case 'in_progress': + return 'bg-[#2196f3]'; + case 'completed': + return 'bg-[#4caf50]'; + case 'failed': + return 'bg-[#f44336]'; + default: + return 'bg-gray-500'; + } +}; + +/** + * Status indicator with colored dot + */ +export const StatusIndicator: React.FC = ({ + status, + text, +}) => ( +
+ + {text} +
+); + +interface CodeBlockProps { + children: string; +} + +/** + * Code block for displaying formatted code or output + */ +export const CodeBlock: React.FC = ({ children }) => ( +
+    {children}
+  
+); + +/** + * Props for LocationsList + */ +interface LocationsListProps { + locations: Array<{ + path: string; + line?: number | null; + }>; +} + +/** + * List of file locations with clickable links + */ +export const LocationsList: React.FC = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts new file mode 100644 index 00000000..0fccb186 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared types for tool call components + */ + +/** + * Tool call content types + */ +export interface ToolCallContent { + type: 'content' | 'diff'; + // For content type + content?: { + type: string; + text?: string; + error?: unknown; + [key: string]: unknown; + }; + // For diff type + path?: string; + oldText?: string | null; + newText?: string; +} + +/** + * Tool call location type + */ +export interface ToolCallLocation { + path: string; + line?: number | null; +} + +/** + * Tool call status type + */ +export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +/** + * Base tool call data interface + */ +export interface ToolCallData { + toolCallId: string; + kind: string; + title: string | object; + status: ToolCallStatus; + rawInput?: string | object; + content?: ToolCallContent[]; + locations?: ToolCallLocation[]; + timestamp?: number; // Add a timestamp field for message sorting +} + +/** + * Base props for all tool call components + */ +export interface BaseToolCallProps { + toolCall: ToolCallData; + // Optional timeline flags for rendering connector line cropping + isFirst?: boolean; + isLast?: boolean; +} + +/** + * Grouped content structure for rendering + */ +export interface GroupedContent { + textOutputs: string[]; + errors: string[]; + diffs: ToolCallContent[]; + otherData: unknown[]; +} 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 new file mode 100644 index 00000000..4ae9efd6 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared utility functions for tool call components + */ + +import type { + ToolCallContent, + GroupedContent, + ToolCallStatus, +} from './types.js'; + +/** + * Format any value to a string for display + */ +export const formatValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + // TODO: Trying to take out the Output part from the string + try { + value = (JSON.parse(value) as { output?: unknown }).output ?? value; + } catch (_error) { + // ignore JSON parse errors + } + return value as string; + } + // Handle Error objects specially + if (value instanceof Error) { + return value.message || value.toString(); + } + // Handle error-like objects with message property + if (typeof value === 'object' && value !== null && 'message' in value) { + const errorObj = value as { message?: string; stack?: string }; + return errorObj.message || String(value); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); +}; + +/** + * Safely convert title to string, handling object types + * Returns empty string if no meaningful title + */ +export const safeTitle = (title: unknown): string => { + if (typeof title === 'string' && title.trim()) { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + 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 + */ +export const shouldShowToolCall = (kind: string): boolean => + !kind.includes('internal'); + +/** + * Check if a tool call has actual output to display + * Returns false for tool calls that completed successfully but have no visible output + */ +export const hasToolCallOutput = ( + toolCall: import('./types.js').ToolCallData, +): boolean => { + // Always show failed tool calls (even without content) + if (toolCall.status === 'failed') { + return true; + } + + // Always show execute/bash/command tool calls (they show the command in title) + const kind = toolCall.kind.toLowerCase(); + if (kind === 'execute' || kind === 'bash' || kind === 'command') { + // But only if they have a title + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + } + + // Show if there are locations (file paths) + if (toolCall.locations && toolCall.locations.length > 0) { + return true; + } + + // Show if there is content + if (toolCall.content && toolCall.content.length > 0) { + const grouped = groupContent(toolCall.content); + // Has any meaningful content? + if ( + grouped.textOutputs.length > 0 || + grouped.errors.length > 0 || + grouped.diffs.length > 0 || + grouped.otherData.length > 0 + ) { + return true; + } + } + + // Show if there's a meaningful title for generic tool calls + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + + // No output, don't show + return false; +}; + +/** + * Group tool call content by type to avoid duplicate labels + */ +export const groupContent = (content?: ToolCallContent[]): GroupedContent => { + const textOutputs: string[] = []; + const errors: string[] = []; + const diffs: ToolCallContent[] = []; + const otherData: unknown[] = []; + + content?.forEach((item) => { + if (item.type === 'diff') { + diffs.push(item); + } else if (item.content) { + const contentObj = item.content; + + // Handle error content + if (contentObj.type === 'error' || 'error' in contentObj) { + // Try to extract meaningful error message + let errorMsg = ''; + + // Check if error is a string + if (typeof contentObj.error === 'string') { + errorMsg = contentObj.error; + } + // Check if error has a message property + else if ( + contentObj.error && + typeof contentObj.error === 'object' && + 'message' in contentObj.error + ) { + errorMsg = (contentObj.error as { message: string }).message; + } + // Try text field + else if (contentObj.text) { + errorMsg = formatValue(contentObj.text); + } + // Format the error object itself + else if (contentObj.error) { + errorMsg = formatValue(contentObj.error); + } + // Fallback + else { + errorMsg = 'An error occurred'; + } + + errors.push(errorMsg); + } + // Handle text content + else if (contentObj.text) { + textOutputs.push(formatValue(contentObj.text)); + } + // Handle other content + else { + otherData.push(contentObj); + } + } + }); + + return { textOutputs, errors, diffs, otherData }; +}; + +/** + * Map a tool call status to a ToolCallContainer status (bullet color) + * - pending/in_progress -> loading + * - completed -> success + * - failed -> error + * - default fallback + */ +export const mapToolStatusToContainerStatus = ( + status: ToolCallStatus, +): 'success' | 'error' | 'warning' | 'loading' | 'default' => { + switch (status) { + case 'pending': + case 'in_progress': + return 'loading'; + case 'failed': + return 'error'; + case 'completed': + return 'success'; + default: + return 'default'; + } +}; diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts new file mode 100644 index 00000000..ab4b70b2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; + +/** + * Auth message handler + * Handles all authentication-related messages + */ +export class AuthMessageHandler extends BaseMessageHandler { + private loginHandler: (() => Promise) | null = null; + + canHandle(messageType: string): boolean { + return ['login'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'login': + await this.handleLogin(); + break; + + default: + console.warn( + '[AuthMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.loginHandler = handler; + } + + /** + * Handle login request + */ + private async handleLogin(): Promise { + try { + console.log('[AuthMessageHandler] Login requested'); + console.log( + '[AuthMessageHandler] Login handler available:', + !!this.loginHandler, + ); + + // Direct login without additional confirmation + if (this.loginHandler) { + console.log('[AuthMessageHandler] Calling login handler'); + await this.loginHandler(); + console.log( + '[AuthMessageHandler] Login handler completed successfully', + ); + } else { + console.log('[AuthMessageHandler] Using fallback login method'); + // Fallback: show message and use command + vscode.window.showInformationMessage( + 'Please wait while we connect to Qwen Code...', + ); + await vscode.commands.executeCommand('qwen-code.login'); + } + } catch (error) { + console.error('[AuthMessageHandler] Login failed:', error); + console.error( + '[AuthMessageHandler] Error stack:', + error instanceof Error ? error.stack : 'N/A', + ); + this.sendToWebView({ + type: 'loginError', + data: { + message: `Login failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts new file mode 100644 index 00000000..4d01fd02 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; + +/** + * Base message handler interface + * All sub-handlers should implement this interface + */ +export interface IMessageHandler { + /** + * Handle message + * @param message - Message object + * @returns Promise + */ + handle(message: { type: string; data?: unknown }): Promise; + + /** + * Check if this handler can handle the message type + * @param messageType - Message type + * @returns boolean + */ + canHandle(messageType: string): boolean; +} + +/** + * Base message handler class + * Provides common dependency injection and helper methods + */ +export abstract class BaseMessageHandler implements IMessageHandler { + constructor( + protected agentManager: QwenAgentManager, + protected conversationStore: ConversationStore, + protected currentConversationId: string | null, + protected sendToWebView: (message: unknown) => void, + ) {} + + abstract handle(message: { type: string; data?: unknown }): Promise; + abstract canHandle(messageType: string): boolean; + + /** + * Update current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts new file mode 100644 index 00000000..7d82315d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../utils/webviewUtils.js'; + +/** + * Editor message handler + * Handles all editor state-related messages + */ +export class EditorMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return ['getActiveEditor', 'focusActiveEditor'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'getActiveEditor': + await this.handleGetActiveEditor(); + break; + + case 'focusActiveEditor': + await this.handleFocusActiveEditor(); + break; + + default: + console.warn( + '[EditorMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current active editor info + */ + private async handleGetActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + const filePath = activeEditor.document.uri.fsPath; + const fileName = getFileName(filePath); + + let selectionInfo = null; + if (!activeEditor.selection.isEmpty) { + const selection = activeEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } else { + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName: null, filePath: null, selection: null }, + }); + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to get active editor:', + error, + ); + } + } + + /** + * Focus on active editor + */ + private async handleFocusActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + await vscode.window.showTextDocument(activeEditor.document, { + viewColumn: activeEditor.viewColumn, + preserveFocus: false, + }); + } else { + // If no active editor, show file picker + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Open', + }); + + if (uri && uri.length > 0) { + await vscode.window.showTextDocument(uri[0]); + } + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to focus active editor:', + error, + ); + vscode.window.showErrorMessage(`Failed to focus editor: ${error}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts new file mode 100644 index 00000000..f82525f7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -0,0 +1,446 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../utils/webviewUtils.js'; +import { showDiffCommand } from '../../commands/index.js'; + +/** + * File message handler + * Handles all file-related messages + */ +export class FileMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return [ + 'attachFile', + 'showContextPicker', + 'getWorkspaceFiles', + 'openFile', + 'openDiff', + 'createAndOpenTempFile', + ].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'attachFile': + await this.handleAttachFile(); + break; + + case 'showContextPicker': + await this.handleShowContextPicker(); + break; + + case 'getWorkspaceFiles': + await this.handleGetWorkspaceFiles(data?.query as string | undefined); + break; + + case 'openFile': + await this.handleOpenFile(data?.path as string | undefined); + break; + + case 'openDiff': + console.log('[FileMessageHandler ===== ] openDiff called with:', data); + await this.handleOpenDiff(data); + break; + + case 'createAndOpenTempFile': + await this.handleCreateAndOpenTempFile(data); + break; + + default: + console.warn( + '[FileMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Handle attach file request + */ + private async handleAttachFile(): Promise { + try { + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uris && uris.length > 0) { + const uri = uris[0]; + const fileName = getFileName(uri.fsPath); + + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri.fsPath, + }, + }); + } + } catch (error) { + console.error('[FileMessageHandler] Failed to attach file:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to attach file: ${error}` }, + }); + } + } + + /** + * Handle show context picker request + */ + private async handleShowContextPicker(): Promise { + try { + const items: vscode.QuickPickItem[] = []; + + // Add current file + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + items.push({ + label: `$(file) ${fileName}`, + description: 'Current file', + detail: activeEditor.document.uri.fsPath, + }); + } + + // Add file picker option + items.push({ + label: '$(file) File...', + description: 'Choose a file to attach', + }); + + // Add workspace files option + items.push({ + label: '$(search) Search files...', + description: 'Search workspace files', + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Attach context', + matchOnDescription: true, + matchOnDetail: true, + }); + + if (selected) { + if (selected.label.includes('Current file') && activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: activeEditor.document.uri.fsPath, + }, + }); + } else if (selected.label.includes('File...')) { + await this.handleAttachFile(); + } else if (selected.label.includes('Search files')) { + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uri && uri.length > 0) { + const fileName = getFileName(uri[0].fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri[0].fsPath, + }, + }); + } + } + } + } catch (error) { + console.error( + '[FileMessageHandler] Failed to show context picker:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to show context picker: ${error}` }, + }); + } + } + + /** + * Get workspace files + */ + private async handleGetWorkspaceFiles(query?: string): Promise { + try { + console.log('[FileMessageHandler] handleGetWorkspaceFiles start', { + query, + }); + const files: Array<{ + id: string; + label: string; + description: string; + path: string; + }> = []; + const addedPaths = new Set(); + + const addFile = (uri: vscode.Uri, isCurrentFile = false) => { + if (addedPaths.has(uri.fsPath)) { + return; + } + + const fileName = getFileName(uri.fsPath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(uri, false) + : uri.fsPath; + + // Filter by query if provided + if ( + query && + !fileName.toLowerCase().includes(query.toLowerCase()) && + !relativePath.toLowerCase().includes(query.toLowerCase()) + ) { + return; + } + + files.push({ + id: isCurrentFile ? 'current-file' : uri.fsPath, + label: fileName, + description: relativePath, + path: uri.fsPath, + }); + addedPaths.add(uri.fsPath); + }; + + // Search or show recent files + if (query) { + // Query mode: perform filesystem search (may take longer on large workspaces) + console.log( + '[FileMessageHandler] Searching workspace files for query', + query, + ); + const uris = await vscode.workspace.findFiles( + `**/*${query}*`, + '**/node_modules/**', + 50, + ); + + for (const uri of uris) { + addFile(uri); + } + } else { + // Non-query mode: respond quickly with currently active and open files + // Add current active file first + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + addFile(activeEditor.document.uri, true); + } + + // Add all open tabs + const tabGroups = vscode.window.tabGroups.all; + for (const tabGroup of tabGroups) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { uri?: vscode.Uri } | undefined; + if (input && input.uri instanceof vscode.Uri) { + addFile(input.uri); + } + } + } + + // Send an initial quick response so UI can render immediately + try { + this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + console.log( + '[FileMessageHandler] Sent initial workspaceFiles (open tabs/active)', + files.length, + ); + } catch (e) { + console.warn( + '[FileMessageHandler] Failed sending initial response', + e, + ); + } + + // If not enough files, add some workspace files (bounded) + if (files.length < 10) { + const recentUris = await vscode.workspace.findFiles( + '**/*', + '**/node_modules/**', + 20, + ); + + for (const uri of recentUris) { + if (files.length >= 20) { + break; + } + addFile(uri); + } + } + } + + this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + console.log( + '[FileMessageHandler] Sent final workspaceFiles', + files.length, + ); + } catch (error) { + console.error( + '[FileMessageHandler] Failed to get workspace files:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get workspace files: ${error}` }, + }); + } + } + + /** + * Open file + */ + private async handleOpenFile(filePath?: string): Promise { + if (!filePath) { + console.warn('[FileMessageHandler] No path provided for openFile'); + return; + } + + try { + console.log('[FileOperations] Opening file:', filePath); + + // Parse file path, line number, and column number + // Formats: path/to/file.ts, path/to/file.ts:123, path/to/file.ts:123:45 + const match = filePath.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/); + if (!match) { + console.warn('[FileOperations] Invalid file path format:', filePath); + return; + } + + const [, path, lineStr, columnStr] = match; + const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers + const columnNumber = columnStr ? parseInt(columnStr, 10) - 1 : 0; // VS Code uses 0-based column numbers + + // Convert to absolute path if relative + let absolutePath = path; + if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) { + // Relative path - resolve against workspace + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath; + } + } + + // Open the document + const uri = vscode.Uri.file(absolutePath); + const document = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }); + + // Navigate to line and column if specified + if (lineStr) { + const position = new vscode.Position(lineNumber, columnNumber); + editor.selection = new vscode.Selection(position, position); + editor.revealRange( + new vscode.Range(position, position), + vscode.TextEditorRevealType.InCenter, + ); + } + + console.log('[FileOperations] File opened successfully:', absolutePath); + } catch (error) { + console.error('[FileMessageHandler] Failed to open file:', error); + vscode.window.showErrorMessage(`Failed to open file: ${error}`); + } + } + + /** + * Open diff view + */ + private async handleOpenDiff( + data: Record | undefined, + ): Promise { + if (!data) { + console.warn('[FileMessageHandler] No data provided for openDiff'); + return; + } + + try { + await vscode.commands.executeCommand(showDiffCommand, { + path: (data.path as string) || '', + oldText: (data.oldText as string) || '', + newText: (data.newText as string) || '', + }); + } catch (error) { + console.error('[FileMessageHandler] Failed to open diff:', error); + vscode.window.showErrorMessage(`Failed to open diff: ${error}`); + } + } + + /** + * Create and open temporary file + */ + private async handleCreateAndOpenTempFile( + data: Record | undefined, + ): Promise { + if (!data) { + console.warn( + '[FileMessageHandler] No data provided for createAndOpenTempFile', + ); + return; + } + + try { + const content = (data.content as string) || ''; + const fileName = (data.fileName as string) || 'temp'; + const fileExtension = (data.fileExtension as string) || '.txt'; + + // Create temporary file path + const tempDir = os.tmpdir(); + const tempFileName = `${fileName}-${Date.now()}${fileExtension}`; + const tempFilePath = path.join(tempDir, tempFileName); + + // Write content to temporary file + await fs.promises.writeFile(tempFilePath, content, 'utf8'); + + // Open the temporary file in VS Code + const uri = vscode.Uri.file(tempFilePath); + await vscode.window.showTextDocument(uri, { + preview: false, + preserveFocus: false, + }); + + console.log( + '[FileMessageHandler] Created and opened temporary file:', + tempFilePath, + ); + } catch (error) { + console.error( + '[FileMessageHandler] Failed to create and open temporary file:', + error, + ); + vscode.window.showErrorMessage( + `Failed to create and open temporary file: ${error}`, + ); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts new file mode 100644 index 00000000..adf94e29 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageHandler } from './BaseMessageHandler.js'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +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 + * Routes messages to appropriate handlers + */ +export class MessageRouter { + private handlers: IMessageHandler[] = []; + private sessionHandler: SessionMessageHandler; + private authHandler: AuthMessageHandler; + private currentConversationId: string | null = null; + private permissionHandler: + | ((message: { type: string; data: { optionId: string } }) => void) + | null = null; + + constructor( + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.currentConversationId = currentConversationId; + + // Initialize all handlers + this.sessionHandler = new SessionMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const fileHandler = new FileMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const editorHandler = new EditorMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + this.authHandler = new AuthMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const settingsHandler = new SettingsMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + // Register handlers in order of priority + this.handlers = [ + this.sessionHandler, + fileHandler, + editorHandler, + this.authHandler, + settingsHandler, + ]; + } + + /** + * Route message to appropriate handler + */ + async route(message: { type: string; data?: unknown }): Promise { + console.log('[MessageRouter] Routing message:', message.type); + + // Handle permission response specially + if (message.type === 'permissionResponse') { + if (this.permissionHandler) { + this.permissionHandler( + message as { type: string; data: { optionId: string } }, + ); + } + return; + } + + // Find appropriate handler + const handler = this.handlers.find((h) => h.canHandle(message.type)); + + if (handler) { + try { + await handler.handle(message); + } catch (error) { + console.error('[MessageRouter] Handler error:', error); + throw error; + } + } else { + console.warn( + '[MessageRouter] No handler found for message type:', + message.type, + ); + } + } + + /** + * Set current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + // Update all handlers + this.handlers.forEach((handler) => { + if ('setCurrentConversationId' in handler) { + ( + handler as { setCurrentConversationId: (id: string | null) => void } + ).setCurrentConversationId(id); + } + }); + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } + + /** + * Set permission handler + */ + setPermissionHandler( + handler: (message: { type: string; data: { optionId: string } }) => void, + ): void { + this.permissionHandler = handler; + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.authHandler.setLoginHandler(handler); + this.sessionHandler?.setLoginHandler?.(handler); + } + + /** + * Append stream content + */ + 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 new file mode 100644 index 00000000..741d9684 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -0,0 +1,1134 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import type { ChatMessage } from '../../services/qwenAgentManager.js'; + +/** + * Session message handler + * Handles all session-related messages + */ +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 + + canHandle(messageType: string): boolean { + return [ + 'sendMessage', + 'newQwenSession', + 'switchQwenSession', + 'getQwenSessions', + 'saveSession', + 'resumeSession', + 'cancelStreaming', + // UI action: open a new chat tab (new WebviewPanel) + 'openNewChatTab', + ].includes(messageType); + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.loginHandler = handler; + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'sendMessage': + await this.handleSendMessage( + (data?.text as string) || '', + data?.context as + | Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> + | undefined, + data?.fileContext as + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined, + ); + break; + + case 'newQwenSession': + await this.handleNewQwenSession(); + break; + + case 'switchQwenSession': + await this.handleSwitchQwenSession((data?.sessionId as string) || ''); + break; + + case 'getQwenSessions': + await this.handleGetQwenSessions( + (data?.cursor as number | undefined) ?? undefined, + (data?.size as number | undefined) ?? undefined, + ); + break; + + case 'saveSession': + await this.handleSaveSession((data?.tag as string) || ''); + break; + + case 'resumeSession': + await this.handleResumeSession((data?.sessionId as string) || ''); + break; + + case 'openNewChatTab': + // Open a brand new chat tab (WebviewPanel) via the extension command + // This does not alter the current conversation in this tab; the new tab + // will initialize its own state and (optionally) create a new session. + try { + await vscode.commands.executeCommand('qwenCode.openNewChatTab'); + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to open new chat tab:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to open new chat tab: ${error}` }, + }); + } + break; + + case 'cancelStreaming': + // Handle cancel streaming request from webview + await this.handleCancelStreaming(); + break; + + default: + console.warn( + '[SessionMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current stream content + */ + getCurrentStreamContent(): string { + return this.currentStreamContent; + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.currentStreamContent += chunk; + } + + /** + * Reset stream content + */ + resetStreamContent(): void { + this.currentStreamContent = ''; + } + + /** + * Check if saving checkpoint + */ + getIsSavingCheckpoint(): boolean { + return this.isSavingCheckpoint; + } + + /** + * Handle send message request + */ + private async handleSendMessage( + text: string, + context?: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }>, + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }, + ): Promise { + console.log('[SessionMessageHandler] handleSendMessage called with:', text); + + // Format message with file context if present + let formattedText = text; + if (context && context.length > 0) { + const contextParts = context + .map((ctx) => { + if (ctx.startLine && ctx.endLine) { + return `${ctx.value}#${ctx.startLine}${ctx.startLine !== ctx.endLine ? `-${ctx.endLine}` : ''}`; + } + return ctx.value; + }) + .join('\n'); + + formattedText = `${contextParts}\n\n${text}`; + } + + // Ensure we have an active conversation + if (!this.currentConversationId) { + console.log( + '[SessionMessageHandler] No active conversation, creating one...', + ); + try { + const newConv = await this.conversationStore.createConversation(); + this.currentConversationId = newConv.id; + this.sendToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + } catch (error) { + const errorMsg = `Failed to create conversation: ${error}`; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + } + + if (!this.currentConversationId) { + const errorMsg = + 'Failed to create conversation. Please restart the extension.'; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + + // Check if this is the first message + let isFirstMessage = false; + try { + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + isFirstMessage = !conversation || conversation.messages.length === 0; + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to check conversation:', + error, + ); + } + + // Generate title for first message, but only if it hasn't been set yet + if (isFirstMessage && !this.isTitleSet) { + const title = text.substring(0, 50) + (text.length > 50 ? '...' : ''); + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { + sessionId: this.currentConversationId, + title, + }, + }); + this.isTitleSet = true; // Mark title as set + } + + // Save user message + const userMessage: ChatMessage = { + role: 'user', + content: text, + timestamp: Date.now(), + }; + + await this.conversationStore.addMessage( + this.currentConversationId, + userMessage, + ); + + // Send to WebView + this.sendToWebView({ + type: 'message', + data: { ...userMessage, fileContext }, + }); + + // Check if agent is connected + if (!this.agentManager.isConnected) { + 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'); + } + } + return; + } + + // Send to agent + try { + this.resetStreamContent(); + + this.sendToWebView({ + type: 'streamStart', + data: { timestamp: Date.now() }, + }); + + await this.agentManager.sendMessage(formattedText); + + // Save assistant message + if (this.currentStreamContent && this.currentConversationId) { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: this.currentStreamContent, + timestamp: Date.now(), + }; + await this.conversationStore.addMessage( + this.currentConversationId, + assistantMessage, + ); + } + + this.sendToWebView({ + 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); + + const err = error as unknown as Error; + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + const lower = errorMsg.toLowerCase(); + + // Suppress user-cancelled/aborted errors (ESC/Stop button) + const isAbortLike = + (err && (err as Error).name === 'AbortError') || + lower.includes('abort') || + lower.includes('aborted') || + lower.includes('request was aborted') || + lower.includes('canceled') || + lower.includes('cancelled') || + lower.includes('user_cancelled'); + + if (isAbortLike) { + // Do not show VS Code error popup for intentional cancellations. + // Ensure the webview knows the stream ended due to user action. + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + return; + } + // Check for session not found error and handle it appropriately + if ( + errorMsg.includes('Session not found') || + errorMsg.includes('No active ACP session') || + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + vscode.window.showErrorMessage(`Error sending message: ${error}`); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + } + } + } + + /** + * Handle new Qwen session request + */ + private async handleNewQwenSession(): Promise { + try { + console.log('[SessionMessageHandler] Creating new Qwen session...'); + + // Ensure connection (login) before creating a new session + if (!this.agentManager.isConnected) { + const result = await vscode.window.showWarningMessage( + '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 { + 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(); + + await this.agentManager.createNewSession(workingDir); + + this.sendToWebView({ + type: 'conversationCleared', + data: {}, + }); + + // Reset title flag when creating a new session + this.isTitleSet = false; + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to create new session:', + error, + ); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to create new session: ${error}` }, + }); + } + } + } + + /** + * Handle switch Qwen session request + */ + private async handleSwitchQwenSession(sessionId: string): Promise { + try { + console.log('[SessionMessageHandler] Switching to session:', sessionId); + + // If not connected yet, offer to login or view offline + if (!this.agentManager.isConnected) { + const selection = await vscode.window.showWarningMessage( + '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') { + // Show messages from local cache only + const messages = + await this.agentManager.getSessionMessages(sessionId); + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages }, + }); + vscode.window.showInformationMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + return; + } else { + // 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 { + const allSessions = await this.agentManager.getSessionList(); + sessionDetails = + allSessions.find( + (s: { id?: string; sessionId?: string }) => + s.id === sessionId || s.sessionId === sessionId, + ) || null; + } catch (err) { + console.log( + '[SessionMessageHandler] Could not get session details:', + err, + ); + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // Try to load session via ACP (now we should be connected) + try { + // Set current id and clear UI first so replayed updates append afterwards + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages: [], session: sessionDetails }, + }); + + const loadResponse = await this.agentManager.loadSessionViaAcp( + sessionId, + (sessionDetails?.cwd as string | undefined) || undefined, + ); + console.log( + '[SessionMessageHandler] session/load succeeded (per ACP spec result is null; actual history comes via session/update):', + loadResponse, + ); + + // Reset title flag when switching sessions + this.isTitleSet = false; + + // Successfully loaded session, return early to avoid fallback logic + return; + } catch (loadError) { + console.warn( + '[SessionMessageHandler] session/load failed, using fallback:', + loadError, + ); + + // Safely convert error to string + const errorMsg = loadError ? String(loadError) : 'Unknown error'; + + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + + // Fallback: create new session + const messages = await this.agentManager.getSessionMessages(sessionId); + + // If we are connected, try to create a fresh ACP session so user can interact + if (this.agentManager.isConnected) { + try { + const newAcpSessionId = + await this.agentManager.createNewSession(workingDir); + + this.currentConversationId = newAcpSessionId; + + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + + // Only show the cache warning if we actually fell back to local cache + // and didn't successfully load via ACP + // Check if we truly fell back by checking if loadError is not null/undefined + // and if it's not a successful response that looks like an error + if ( + loadError && + typeof loadError === 'object' && + !('result' in loadError) + ) { + vscode.window.showWarningMessage( + 'Session restored from local cache. Some context may be incomplete.', + ); + } + } catch (createError) { + console.error( + '[SessionMessageHandler] Failed to create session:', + createError, + ); + + // Safely convert error to string + const createErrorMsg = createError + ? String(createError) + : 'Unknown error'; + // Check for authentication/session expiration errors in session creation + if ( + createErrorMsg.includes('Authentication required') || + createErrorMsg.includes('(code: -32000)') || + createErrorMsg.includes('Unauthorized') || + createErrorMsg.includes('Invalid token') || + createErrorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + + throw createError; + } + } else { + // Offline view only + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + vscode.window.showWarningMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + } + } + } catch (error) { + console.error('[SessionMessageHandler] Failed to switch session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to switch session: ${error}` }, + }); + } + } + } + + /** + * Handle get Qwen sessions request + */ + private async handleGetQwenSessions( + cursor?: number, + size?: number, + ): Promise { + try { + // Paged when possible; falls back to full list if ACP not supported + const page = await this.agentManager.getSessionListPaged({ + cursor, + size, + }); + const append = typeof cursor === 'number'; + this.sendToWebView({ + type: 'qwenSessionList', + data: { + sessions: page.sessions, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + append, + }, + }); + } catch (error) { + console.error('[SessionMessageHandler] Failed to get sessions:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get sessions: ${error}` }, + }); + } + } + } + + /** + * Handle save session request + */ + private async handleSaveSession(tag: string): Promise { + try { + if (!this.currentConversationId) { + 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( + this.currentConversationId, + tag, + ); + + this.sendToWebView({ + type: 'saveSessionResponse', + data: response, + }); + } catch (acpError) { + // Safely convert error to string + const errorMsg = acpError ? String(acpError) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + + // Fallback to direct save + const response = await this.agentManager.saveSessionDirect( + messages, + tag, + ); + + this.sendToWebView({ + type: 'saveSessionResponse', + data: response, + }); + } + + await this.handleGetQwenSessions(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to save session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'saveSessionResponse', + data: { + success: false, + message: `Failed to save session: ${error}`, + }, + }); + } + } + } + + /** + * Handle cancel streaming request + */ + private async handleCancelStreaming(): Promise { + try { + console.log('[SessionMessageHandler] Canceling streaming...'); + + // Cancel the current streaming operation in the agent manager + await this.agentManager.cancelCurrentPrompt(); + + // Send streamEnd message to WebView to update UI + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + + console.log('[SessionMessageHandler] Streaming cancelled successfully'); + } catch (_error) { + console.log('[SessionMessageHandler] Streaming cancelled (interrupted)'); + + // Always send streamEnd to update UI, regardless of errors + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + } + } + + /** + * Handle resume session request + */ + private async handleResumeSession(sessionId: string): Promise { + try { + // If not connected, offer to login or view offline + if (!this.agentManager.isConnected) { + const selection = await vscode.window.showWarningMessage( + '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') { + const messages = + await this.agentManager.getSessionMessages(sessionId); + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages }, + }); + vscode.window.showInformationMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + return; + } else { + return; + } + } + + // Try ACP load first + try { + // Pre-clear UI so replayed updates append afterwards + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages: [] }, + }); + + await this.agentManager.loadSessionViaAcp(sessionId); + + // Reset title flag when resuming sessions + this.isTitleSet = false; + + // Successfully loaded session, return early to avoid fallback logic + await this.handleGetQwenSessions(); + return; + } catch (acpError) { + // Safely convert error to string + const errorMsg = acpError ? String(acpError) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + 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(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to resume session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + const result = await vscode.window.showWarningMessage( + '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', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to resume session: ${error}` }, + }); + } + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts new file mode 100644 index 00000000..7ea8e732 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts @@ -0,0 +1,101 @@ +/** + * @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 new file mode 100644 index 00000000..eca8437d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useRef } from 'react'; +import type { VSCodeAPI } from '../../hooks/useVSCode.js'; + +/** + * File context management Hook + * Manages active file, selection content, and workspace file list + */ +export const useFileContext = (vscode: VSCodeAPI) => { + const [activeFileName, setActiveFileName] = useState(null); + const [activeFilePath, setActiveFilePath] = useState(null); + const [activeSelection, setActiveSelection] = useState<{ + startLine: number; + endLine: number; + } | null>(null); + + const [workspaceFiles, setWorkspaceFiles] = useState< + Array<{ + id: string; + label: string; + description: string; + path: string; + }> + >([]); + + // File reference mapping: @filename -> full path + const fileReferenceMap = useRef>(new Map()); + + // Whether workspace files have been requested + const hasRequestedFilesRef = useRef(false); + + // Search debounce timer + const searchTimerRef = useRef(null); + + /** + * Request workspace files + */ + const requestWorkspaceFiles = useCallback( + (query?: string) => { + if (!hasRequestedFilesRef.current && !query) { + hasRequestedFilesRef.current = true; + } + + // If there's a query, clear previous timer and set up debounce + if (query && query.length >= 1) { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + searchTimerRef.current = setTimeout(() => { + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: { query }, + }); + }, 300); + } else { + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: query ? { query } : {}, + }); + } + }, + [vscode], + ); + + /** + * Add file reference + */ + const addFileReference = useCallback((fileName: string, filePath: string) => { + fileReferenceMap.current.set(fileName, filePath); + }, []); + + /** + * Get file reference + */ + const getFileReference = useCallback( + (fileName: string) => fileReferenceMap.current.get(fileName), + [], + ); + + /** + * Clear file references + */ + const clearFileReferences = useCallback(() => { + fileReferenceMap.current.clear(); + }, []); + + /** + * Request active editor info + */ + const requestActiveEditor = useCallback(() => { + vscode.postMessage({ type: 'getActiveEditor', data: {} }); + }, [vscode]); + + /** + * Focus on active editor + */ + const focusActiveEditor = useCallback(() => { + vscode.postMessage({ + type: 'focusActiveEditor', + data: {}, + }); + }, [vscode]); + + return { + // State + activeFileName, + activeFilePath, + activeSelection, + workspaceFiles, + hasRequestedFiles: hasRequestedFilesRef.current, + + // State setters + setActiveFileName, + setActiveFilePath, + setActiveSelection, + setWorkspaceFiles, + + // File reference operations + addFileReference, + getFileReference, + clearFileReferences, + + // Operations + requestWorkspaceFiles, + requestActiveEditor, + focusActiveEditor, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts new file mode 100644 index 00000000..17fde331 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useRef, useCallback } from 'react'; + +export interface TextMessage { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; +} + +/** + * Message handling Hook + * Manages message list, streaming responses, and loading state + */ +export const useMessageHandling = () => { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(''); + // Track the index of the assistant placeholder message during streaming + const streamingMessageIndexRef = useRef(null); + // Track the index of the current aggregated thinking message + const thinkingMessageIndexRef = useRef(null); + + /** + * Add message + */ + const addMessage = useCallback((message: TextMessage) => { + setMessages((prev) => [...prev, message]); + }, []); + + /** + * Clear messages + */ + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + /** + * Start streaming response + */ + const startStreaming = useCallback((timestamp?: number) => { + // Create an assistant placeholder message immediately so tool calls won't jump before it + setMessages((prev) => { + // Record index of the placeholder to update on chunks + streamingMessageIndexRef.current = prev.length; + return [ + ...prev, + { + role: 'assistant', + content: '', + // Use provided timestamp (from extension) to keep ordering stable + timestamp: typeof timestamp === 'number' ? timestamp : Date.now(), + }, + ]; + }); + setIsStreaming(true); + }, []); + + /** + * Add stream chunk + */ + const appendStreamChunk = useCallback( + (chunk: string) => { + // Ignore late chunks after user cancelled streaming (until next streamStart) + if (!isStreaming) { + return; + } + + setMessages((prev) => { + let idx = streamingMessageIndexRef.current; + const next = prev.slice(); + + // If there is no active placeholder (e.g., after a tool call), start a new one + if (idx === null) { + idx = next.length; + streamingMessageIndexRef.current = idx; + next.push({ role: 'assistant', content: '', timestamp: Date.now() }); + } + + if (idx < 0 || idx >= next.length) { + return prev; + } + const target = next[idx]; + next[idx] = { ...target, content: (target.content || '') + chunk }; + return next; + }); + }, + [isStreaming], + ); + + /** + * Break current assistant stream segment (e.g., when a tool call starts/updates) + * Next incoming chunk will create a new assistant placeholder + */ + const breakAssistantSegment = useCallback(() => { + streamingMessageIndexRef.current = null; + }, []); + + /** + * End streaming response + */ + const endStreaming = useCallback(() => { + // Finalize streaming; content already lives in the placeholder message + setIsStreaming(false); + streamingMessageIndexRef.current = null; + // Remove the thinking message if it exists (collapse thoughts) + setMessages((prev) => { + const idx = thinkingMessageIndexRef.current; + thinkingMessageIndexRef.current = null; + if (idx === null || idx < 0 || idx >= prev.length) { + return prev; + } + const next = prev.slice(); + next.splice(idx, 1); + return next; + }); + }, []); + + /** + * Set waiting for response state + */ + const setWaitingForResponse = useCallback((message: string) => { + setIsWaitingForResponse(true); + setLoadingMessage(message); + }, []); + + /** + * Clear waiting for response state + */ + const clearWaitingForResponse = useCallback(() => { + setIsWaitingForResponse(false); + setLoadingMessage(''); + }, []); + + return { + // State + messages, + isStreaming, + isWaitingForResponse, + loadingMessage, + + // Operations + addMessage, + clearMessages, + startStreaming, + appendStreamChunk, + endStreaming, + // Thought handling + appendThinkingChunk: (chunk: string) => { + // Ignore late thoughts after user cancelled streaming + if (!isStreaming) { + return; + } + setMessages((prev) => { + let idx = thinkingMessageIndexRef.current; + const next = prev.slice(); + if (idx === null) { + idx = next.length; + thinkingMessageIndexRef.current = idx; + next.push({ role: 'thinking', content: '', timestamp: Date.now() }); + } + if (idx >= 0 && idx < next.length) { + const target = next[idx]; + next[idx] = { ...target, content: (target.content || '') + chunk }; + } + return next; + }); + }, + clearThinking: () => { + setMessages((prev) => { + const idx = thinkingMessageIndexRef.current; + thinkingMessageIndexRef.current = null; + if (idx === null || idx < 0 || idx >= prev.length) { + return prev; + } + const next = prev.slice(); + next.splice(idx, 1); + return next; + }); + }, + breakAssistantSegment, + setWaitingForResponse, + clearWaitingForResponse, + setMessages, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts new file mode 100644 index 00000000..9fba4a80 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useMemo } from 'react'; +import type { VSCodeAPI } from '../../hooks/useVSCode.js'; + +/** + * Session management Hook + * Manages session list, current session, session switching, and search + */ +export const useSessionManagement = (vscode: VSCodeAPI) => { + const [qwenSessions, setQwenSessions] = useState< + Array> + >([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [currentSessionTitle, setCurrentSessionTitle] = + useState('Past Conversations'); + const [showSessionSelector, setShowSessionSelector] = useState(false); + const [sessionSearchQuery, setSessionSearchQuery] = useState(''); + const [savedSessionTags, setSavedSessionTags] = useState([]); + const [nextCursor, setNextCursor] = useState(undefined); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const PAGE_SIZE = 20; + + /** + * Filter session list + */ + const filteredSessions = useMemo(() => { + if (!sessionSearchQuery.trim()) { + return qwenSessions; + } + const query = sessionSearchQuery.toLowerCase(); + return qwenSessions.filter((session) => { + const title = ( + (session.title as string) || + (session.name as string) || + '' + ).toLowerCase(); + return title.includes(query); + }); + }, [qwenSessions, sessionSearchQuery]); + + /** + * Load session list + */ + const handleLoadQwenSessions = useCallback(() => { + // Reset pagination state and load first page + setQwenSessions([]); + setNextCursor(undefined); + setHasMore(true); + setIsLoading(true); + vscode.postMessage({ type: 'getQwenSessions', data: { size: PAGE_SIZE } }); + setShowSessionSelector(true); + }, [vscode]); + + const handleLoadMoreSessions = useCallback(() => { + if (!hasMore || isLoading || nextCursor === undefined) { + return; + } + setIsLoading(true); + vscode.postMessage({ + type: 'getQwenSessions', + data: { cursor: nextCursor, size: PAGE_SIZE }, + }); + }, [hasMore, isLoading, nextCursor, vscode]); + + /** + * Create new session + */ + const handleNewQwenSession = useCallback(() => { + vscode.postMessage({ type: 'openNewChatTab', data: {} }); + setShowSessionSelector(false); + }, [vscode]); + + /** + * Switch session + */ + const handleSwitchSession = useCallback( + (sessionId: string) => { + if (sessionId === currentSessionId) { + console.log('[useSessionManagement] Already on this session, ignoring'); + setShowSessionSelector(false); + return; + } + + console.log('[useSessionManagement] Switching to session:', sessionId); + vscode.postMessage({ + type: 'switchQwenSession', + data: { sessionId }, + }); + }, + [currentSessionId, vscode], + ); + + /** + * Save session + */ + const handleSaveSession = useCallback( + (tag: string) => { + vscode.postMessage({ + type: 'saveSession', + data: { tag }, + }); + }, + [vscode], + ); + + /** + * Handle Save session response + */ + const handleSaveSessionResponse = useCallback( + (response: { success: boolean; message?: string }) => { + if (response.success) { + if (response.message) { + const tagMatch = response.message.match(/tag: (.+)$/); + if (tagMatch) { + setSavedSessionTags((prev) => [...prev, tagMatch[1]]); + } + } + } else { + console.error('Failed to save session:', response.message); + } + }, + [], + ); + + return { + // State + qwenSessions, + currentSessionId, + currentSessionTitle, + showSessionSelector, + sessionSearchQuery, + filteredSessions, + savedSessionTags, + nextCursor, + hasMore, + isLoading, + + // State setters + setQwenSessions, + setCurrentSessionId, + setCurrentSessionTitle, + setShowSessionSelector, + setSessionSearchQuery, + setSavedSessionTags, + setNextCursor, + setHasMore, + setIsLoading, + + // Operations + handleLoadQwenSessions, + handleNewQwenSession, + handleSwitchSession, + handleSaveSession, + handleSaveSessionResponse, + handleLoadMoreSessions, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts new file mode 100644 index 00000000..8f6848c1 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RefObject } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import type { CompletionItem } from '../../types/completionItemTypes.js'; + +interface CompletionTriggerState { + isOpen: boolean; + triggerChar: '@' | '/' | null; + query: string; + position: { top: number; left: number }; + items: CompletionItem[]; +} + +/** + * Hook to handle @ and / completion triggers in contentEditable + * Based on vscode-copilot-chat's AttachContextAction + */ +export function useCompletionTrigger( + inputRef: RefObject, + getCompletionItems: ( + trigger: '@' | '/', + query: string, + ) => Promise, +) { + // Show immediate loading and provide a timeout fallback for slow sources + const LOADING_ITEM = useMemo( + () => ({ + id: 'loading', + label: 'Loading…', + type: 'info', + }), + [], + ); + + const TIMEOUT_ITEM = useMemo( + () => ({ + id: 'timeout', + label: 'Timeout', + type: 'info', + }), + [], + ); + const TIMEOUT_MS = 5000; + + const [state, setState] = useState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + + // Timer for loading timeout + const timeoutRef = useRef | null>(null); + + const closeCompletion = useCallback(() => { + // Clear pending timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + }, []); + + const openCompletion = useCallback( + async ( + trigger: '@' | '/', + query: string, + position: { top: number; left: number }, + ) => { + // Clear previous timeout if any + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // Open immediately with a loading placeholder + setState({ + isOpen: true, + triggerChar: trigger, + query, + position, + items: [LOADING_ITEM], + }); + + // Schedule a timeout fallback if loading takes too long + timeoutRef.current = setTimeout(() => { + setState((prev) => { + // Only show timeout if still open and still for the same request + if ( + prev.isOpen && + prev.triggerChar === trigger && + prev.query === query && + prev.items.length > 0 && + prev.items[0]?.id === 'loading' + ) { + return { ...prev, items: [TIMEOUT_ITEM] }; + } + return prev; + }); + }, TIMEOUT_MS); + + const items = await getCompletionItems(trigger, query); + + // Clear timeout on success + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + setState((prev) => ({ + ...prev, + isOpen: true, + triggerChar: trigger, + query, + position, + items, + })); + }, + [getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM], + ); + + const refreshCompletion = useCallback(async () => { + if (!state.isOpen || !state.triggerChar) { + return; + } + const items = await getCompletionItems(state.triggerChar, state.query); + setState((prev) => ({ ...prev, items })); + }, [state.isOpen, state.triggerChar, state.query, getCompletionItems]); + + useEffect(() => { + const inputElement = inputRef.current; + if (!inputElement) { + return; + } + + const getCursorPosition = (): { top: number; left: number } | null => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + + try { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // If the range has a valid position, use it + if (rect.top > 0 && rect.left > 0) { + return { + top: rect.top, + left: rect.left, + }; + } + + // Fallback: use input element's position + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } catch (error) { + console.error( + '[useCompletionTrigger] Error getting cursor position:', + error, + ); + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } + }; + + const handleInput = async () => { + const text = inputElement.textContent || ''; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + console.log('[useCompletionTrigger] No selection or rangeCount === 0'); + return; + } + + const range = selection.getRangeAt(0); + + // Get cursor position more reliably + // For contentEditable, we need to calculate the actual text offset + let cursorPosition = text.length; // Default to end of text + + if (range.startContainer === inputElement) { + // Cursor is directly in the container (e.g., empty or at boundary) + // Use childNodes to determine position + const childIndex = range.startOffset; + let offset = 0; + for ( + let i = 0; + i < childIndex && i < inputElement.childNodes.length; + i++ + ) { + offset += inputElement.childNodes[i].textContent?.length || 0; + } + cursorPosition = offset || text.length; + } else if (range.startContainer.nodeType === Node.TEXT_NODE) { + // Cursor is in a text node - calculate offset from start of input + const walker = document.createTreeWalker( + inputElement, + NodeFilter.SHOW_TEXT, + null, + ); + + let offset = 0; + let found = false; + let node: Node | null = walker.nextNode(); + while (node) { + if (node === range.startContainer) { + offset += range.startOffset; + found = true; + break; + } + offset += node.textContent?.length || 0; + node = walker.nextNode(); + } + // If we found the node, use the calculated offset; otherwise use text length + cursorPosition = found ? offset : text.length; + } + + // Find trigger character before cursor + // Use text length if cursorPosition is 0 but we have text (edge case for first character) + const effectiveCursorPosition = + cursorPosition === 0 && text.length > 0 ? text.length : cursorPosition; + + const textBeforeCursor = text.substring(0, effectiveCursorPosition); + const lastAtMatch = textBeforeCursor.lastIndexOf('@'); + const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); + + // Check if we're in a trigger context + let triggerPos = -1; + let triggerChar: '@' | '/' | null = null; + + if (lastAtMatch > lastSlashMatch) { + triggerPos = lastAtMatch; + triggerChar = '@'; + } else if (lastSlashMatch > lastAtMatch) { + triggerPos = lastSlashMatch; + triggerChar = '/'; + } + + // Check if trigger is at word boundary (start of line or after space) + if (triggerPos >= 0 && triggerChar) { + const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; + const isValidTrigger = + charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + + if (isValidTrigger) { + const query = text.substring(triggerPos + 1, effectiveCursorPosition); + + // Only show if query doesn't contain spaces (still typing the reference) + if (!query.includes(' ') && !query.includes('\n')) { + // Get precise cursor position for menu + const cursorPos = getCursorPosition(); + if (cursorPos) { + await openCompletion(triggerChar, query, cursorPos); + return; + } + } + } + } + + // Close if no valid trigger + if (state.isOpen) { + closeCompletion(); + } + }; + + inputElement.addEventListener('input', handleInput); + return () => inputElement.removeEventListener('input', handleInput); + }, [inputRef, state.isOpen, openCompletion, closeCompletion]); + + return { + isOpen: state.isOpen, + triggerChar: state.triggerChar, + query: state.query, + position: state.position, + items: state.items, + closeCompletion, + openCompletion, + refreshCompletion, + }; +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts new file mode 100644 index 00000000..9f67bcc8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import type { VSCodeAPI } from './useVSCode.js'; +import { getRandomLoadingMessage } from '../../constants/loadingMessages.js'; + +interface UseMessageSubmitProps { + vscode: VSCodeAPI; + inputText: string; + setInputText: (text: string) => void; + inputFieldRef: React.RefObject; + isStreaming: boolean; + // When true, do NOT auto-attach the active editor file/selection to context + skipAutoActiveContext?: boolean; + + fileContext: { + getFileReference: (fileName: string) => string | undefined; + activeFilePath: string | null; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + clearFileReferences: () => void; + }; + + messageHandling: { + setWaitingForResponse: (message: string) => void; + }; +} + +/** + * Message submit Hook + * Handles message submission logic and context parsing + */ +export const useMessageSubmit = ({ + vscode, + inputText, + setInputText, + inputFieldRef, + isStreaming, + skipAutoActiveContext = false, + fileContext, + messageHandling, +}: UseMessageSubmitProps) => { + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (!inputText.trim() || isStreaming) { + return; + } + + // Handle /login command - show inline loading while extension authenticates + if (inputText.trim() === '/login') { + setInputText(''); + if (inputFieldRef.current) { + inputFieldRef.current.textContent = ''; + } + vscode.postMessage({ + type: 'login', + data: {}, + }); + // Show a friendly loading message in the chat while logging in + try { + messageHandling.setWaitingForResponse('Logging in to Qwen Code...'); + } catch (_err) { + // Best-effort UI hint; ignore if hook not available + } + return; + } + + messageHandling.setWaitingForResponse(getRandomLoadingMessage()); + + // Parse @file references from input text + const context: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> = []; + const fileRefPattern = /@([^\s]+)/g; + let match; + + while ((match = fileRefPattern.exec(inputText)) !== null) { + const fileName = match[1]; + const filePath = fileContext.getFileReference(fileName); + + if (filePath) { + context.push({ + type: 'file', + name: fileName, + value: filePath, + }); + } + } + + // Add active file selection context if present and not skipped + if (fileContext.activeFilePath && !skipAutoActiveContext) { + const fileName = fileContext.activeFileName || 'current file'; + context.push({ + type: 'file', + name: fileName, + value: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }); + } + + let fileContextForMessage: + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined; + + if ( + fileContext.activeFilePath && + fileContext.activeFileName && + !skipAutoActiveContext + ) { + fileContextForMessage = { + fileName: fileContext.activeFileName, + filePath: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }; + } + + vscode.postMessage({ + type: 'sendMessage', + data: { + text: inputText, + context: context.length > 0 ? context : undefined, + fileContext: fileContextForMessage, + }, + }); + + setInputText(''); + if (inputFieldRef.current) { + inputFieldRef.current.textContent = ''; + } + fileContext.clearFileReferences(); + }, + [ + inputText, + isStreaming, + setInputText, + inputFieldRef, + vscode, + fileContext, + skipAutoActiveContext, + messageHandling, + ], + ); + + return { handleSubmit }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts new file mode 100644 index 00000000..1b994afd --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import type { ToolCallData } from '../components/messages/toolcalls/ToolCall.js'; +import type { ToolCallUpdate } from '../../types/chatTypes.js'; + +/** + * Tool call management Hook + * Manages tool call states and updates + */ +export const useToolCalls = () => { + const [toolCalls, setToolCalls] = useState>( + new Map(), + ); + + /** + * Handle tool call update + */ + const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => { + setToolCalls((prevToolCalls) => { + const newMap = new Map(prevToolCalls); + const existing = newMap.get(update.toolCallId); + + // Helpers for todo/todos plan merging & content replacement + const isTodoWrite = (kind?: string) => + (kind || '').toLowerCase() === 'todo_write' || + (kind || '').toLowerCase() === 'todowrite' || + (kind || '').toLowerCase() === 'update_todos'; + + const normTitle = (t: unknown) => + typeof t === 'string' ? t.trim().toLowerCase() : ''; + + const isTodoTitleMergeable = (t?: unknown) => { + const nt = normTitle(t); + return nt === 'updated plan' || nt === 'update todos'; + }; + + const extractText = ( + content?: Array<{ + type: 'content' | 'diff'; + content?: { text?: string }; + }>, + ): string => { + if (!content || content.length === 0) { + return ''; + } + const parts: string[] = []; + for (const item of content) { + if (item.type === 'content' && item.content?.text) { + parts.push(String(item.content.text)); + } + } + return parts.join('\n'); + }; + + const normalizeTodoLines = (text: string): string[] => { + if (!text) { + return []; + } + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + return lines.map((line) => { + const idx = line.indexOf('] '); + return idx >= 0 ? line.slice(idx + 2).trim() : line; + }); + }; + + const isSameOrSupplement = ( + prevText: string, + nextText: string, + ): { same: boolean; supplement: boolean } => { + const prev = normalizeTodoLines(prevText); + const next = normalizeTodoLines(nextText); + if (prev.length === next.length) { + const same = prev.every((l, i) => l === next[i]); + if (same) { + return { same: true, supplement: false }; + } + } + // supplement = prev set is subset of next set + const setNext = new Set(next); + const subset = prev.every((l) => setNext.has(l)); + return { same: false, supplement: subset }; + }; + + const safeTitle = (title: unknown): string => { + if (typeof title === 'string') { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + return 'Tool Call'; + }; + + if (update.type === 'tool_call') { + const content = update.content?.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })); + + // Merge strategy: For todo_write + mergeable titles (Updated Plan/Update Todos), + // if it is the same as or a supplement to the most recent similar card, merge the update instead of adding new. + if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) { + const nextText = extractText(content); + // Find the most recent card with todo_write + mergeable title + let lastId: string | null = null; + let lastText = ''; + let lastTimestamp = 0; + for (const tc of newMap.values()) { + if ( + isTodoWrite(tc.kind) && + isTodoTitleMergeable(tc.title) && + typeof tc.timestamp === 'number' && + tc.timestamp >= lastTimestamp + ) { + lastId = tc.toolCallId; + lastText = extractText(tc.content); + lastTimestamp = tc.timestamp || 0; + } + } + + if (lastId) { + const cmp = isSameOrSupplement(lastText, nextText); + if (cmp.same) { + // Completely identical: Ignore this addition + return newMap; + } + if (cmp.supplement) { + // Supplement: Replace content to the previous item (using update semantics) + const prev = newMap.get(lastId); + if (prev) { + newMap.set(lastId, { + ...prev, + content, // Override (do not append) + status: update.status || prev.status, + timestamp: update.timestamp || Date.now(), + }); + return newMap; + } + } + } + } + + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: safeTitle(update.title), + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content, + locations: update.locations, + timestamp: update.timestamp || Date.now(), // Add timestamp + }); + } else if (update.type === 'tool_call_update') { + const updatedContent = update.content + ? update.content.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })) + : undefined; + + if (existing) { + // Default behavior is to append; but for todo_write + mergeable titles, use replacement to avoid stacking duplicates + let mergedContent = existing.content; + if (updatedContent) { + if ( + isTodoWrite(update.kind || existing.kind) && + (isTodoTitleMergeable(update.title) || + isTodoTitleMergeable(existing.title)) + ) { + mergedContent = updatedContent; // Override + } else { + mergedContent = [...(existing.content || []), ...updatedContent]; + } + } + // If tool call has just completed/failed, bump timestamp to now for correct ordering + const isFinal = + update.status === 'completed' || update.status === 'failed'; + const nextTimestamp = isFinal + ? Date.now() + : update.timestamp || existing.timestamp || Date.now(); + + newMap.set(update.toolCallId, { + ...existing, + ...(update.kind && { kind: update.kind }), + ...(update.title && { title: safeTitle(update.title) }), + ...(update.status && { status: update.status }), + content: mergedContent, + ...(update.locations && { locations: update.locations }), + timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed) + }); + } else { + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: update.title ? safeTitle(update.title) : '', + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content: updatedContent, + locations: update.locations, + timestamp: update.timestamp || Date.now(), // Add timestamp + }); + } + } + + return newMap; + }); + }, []); + + /** + * Clear all tool calls + */ + const clearToolCalls = useCallback(() => { + setToolCalls(new Map()); + }, []); + + /** + * Get in-progress tool calls + */ + const inProgressToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'pending' || toolCall.status === 'in_progress', + ); + + /** + * Get completed tool calls + */ + const completedToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'completed' || toolCall.status === 'failed', + ); + + return { + toolCalls, + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts new file mode 100644 index 00000000..1a161346 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface VSCodeAPI { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; +} + +declare const acquireVsCodeApi: () => VSCodeAPI; + +/** + * Module-level VS Code API instance cache + * acquireVsCodeApi() can only be called once, must be cached at module level + */ +let vscodeApiInstance: VSCodeAPI | null = null; + +/** + * Get VS Code API instance + * Uses module-level cache to ensure acquireVsCodeApi() is only called once + */ +function getVSCodeAPI(): VSCodeAPI { + if (vscodeApiInstance) { + return vscodeApiInstance; + } + + if (typeof acquireVsCodeApi !== 'undefined') { + vscodeApiInstance = acquireVsCodeApi(); + return vscodeApiInstance; + } + + // Fallback for development/testing + vscodeApiInstance = { + postMessage: (message: unknown) => { + console.log('Mock postMessage:', message); + }, + getState: () => ({}), + setState: (state: unknown) => { + console.log('Mock setState:', state); + }, + }; + return vscodeApiInstance; +} + +/** + * Hook to get VS Code API + * Multiple components can safely call this hook, API instance will be reused + */ +export function useVSCode(): VSCodeAPI { + return getVSCodeAPI(); +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts new file mode 100644 index 00000000..cd312361 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -0,0 +1,802 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { useVSCode } from './useVSCode.js'; +import type { Conversation } from '../../services/conversationStore.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, +} from '../components/PermissionDrawer/PermissionRequest.js'; +import type { ToolCallUpdate } from '../../types/chatTypes.js'; +import type { ApprovalModeValue } from '../../types/acpTypes.js'; +import type { PlanEntry } from '../../types/chatTypes.js'; + +interface UseWebViewMessagesProps { + // Session management + sessionManagement: { + currentSessionId: string | null; + setQwenSessions: ( + sessions: + | Array> + | (( + prev: Array>, + ) => Array>), + ) => void; + setCurrentSessionId: (id: string | null) => void; + setCurrentSessionTitle: (title: string) => void; + setShowSessionSelector: (show: boolean) => void; + setNextCursor: (cursor: number | undefined) => void; + setHasMore: (hasMore: boolean) => void; + setIsLoading: (loading: boolean) => void; + handleSaveSessionResponse: (response: { + success: boolean; + message?: string; + }) => void; + }; + + // File context + fileContext: { + setActiveFileName: (name: string | null) => void; + setActiveFilePath: (path: string | null) => void; + setActiveSelection: ( + selection: { startLine: number; endLine: number } | null, + ) => void; + setWorkspaceFiles: ( + files: Array<{ + id: string; + label: string; + description: string; + path: string; + }>, + ) => void; + addFileReference: (name: string, path: string) => void; + }; + + // Message handling + messageHandling: { + setMessages: ( + messages: Array<{ + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; + }>, + ) => void; + addMessage: (message: { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + }) => void; + clearMessages: () => void; + startStreaming: (timestamp?: number) => void; + appendStreamChunk: (chunk: string) => void; + endStreaming: () => void; + breakAssistantSegment: () => void; + appendThinkingChunk: (chunk: string) => void; + clearThinking: () => void; + setWaitingForResponse: (message: string) => void; + clearWaitingForResponse: () => void; + }; + + // Tool calls + handleToolCallUpdate: (update: ToolCallUpdate) => void; + clearToolCalls: () => void; + + // Plan + setPlanEntries: (entries: PlanEntry[]) => void; + + // Permission + // When request is non-null, open/update the permission drawer. + // When null, close the drawer (used when extension simulates a choice). + handlePermissionRequest: ( + request: { + options: PermissionOption[]; + toolCall: PermissionToolCall; + } | null, + ) => void; + + // Input + inputFieldRef: React.RefObject; + setInputText: (text: string) => void; + // Edit mode setter (maps ACP modes to UI modes) + setEditMode?: (mode: ApprovalModeValue) => void; +} + +/** + * WebView message handling Hook + * Handles all messages from VSCode Extension uniformly + */ +export const useWebViewMessages = ({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + inputFieldRef, + setInputText, + setEditMode, +}: UseWebViewMessagesProps) => { + // VS Code API for posting messages back to the extension host + const vscode = useVSCode(); + // Track active long-running tool calls (execute/bash/command) so we can + // keep the bottom "waiting" message visible until all of them complete. + const activeExecToolCallsRef = useRef>(new Set()); + // Use ref to store callbacks to avoid useEffect dependency issues + const handlersRef = useRef({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + }); + + // Track last "Updated Plan" snapshot toolcall to support merge/dedupe + const lastPlanSnapshotRef = useRef<{ + id: string; + text: string; // joined lines + lines: string[]; + } | null>(null); + + const buildPlanLines = (entries: PlanEntry[]): string[] => + entries.map((e) => { + const mark = + e.status === 'completed' ? 'x' : e.status === 'in_progress' ? '-' : ' '; + return `- [${mark}] ${e.content}`.trim(); + }); + + const isSupplementOf = ( + prevLines: string[], + nextLines: string[], + ): boolean => { + // Consider "supplement" = old content text collection (ignoring status) is contained in new content + const key = (line: string) => { + const idx = line.indexOf('] '); + return idx >= 0 ? line.slice(idx + 2).trim() : line.trim(); + }; + const nextSet = new Set(nextLines.map(key)); + for (const pl of prevLines) { + if (!nextSet.has(key(pl))) { + return false; + } + } + return true; + }; + + // Update refs + useEffect(() => { + handlersRef.current = { + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + }; + }); + + const handleMessage = useCallback( + (event: MessageEvent) => { + const message = event.data; + const handlers = handlersRef.current; + + switch (message.type) { + case 'modeInfo': { + // Initialize UI mode from ACP initialize + try { + const current = (message.data?.currentModeId || + 'default') as ApprovalModeValue; + setEditMode?.(current); + } catch (_error) { + // best effort + } + break; + } + + case 'modeChanged': { + try { + const modeId = (message.data?.modeId || + 'default') as ApprovalModeValue; + setEditMode?.(modeId); + } catch (_error) { + // Ignore error when setting mode + } + break; + } + case 'loginSuccess': { + // Clear loading state and show a short assistant notice + handlers.messageHandling.clearWaitingForResponse(); + handlers.messageHandling.addMessage({ + role: 'assistant', + content: 'Successfully logged in. You can continue chatting.', + timestamp: Date.now(), + }); + 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.'; + + // 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 '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; + // } + + case 'loginError': { + // Clear loading state and show error notice + handlers.messageHandling.clearWaitingForResponse(); + const errorMsg = + (message?.data?.message as string) || + 'Login failed. Please try again.'; + handlers.messageHandling.addMessage({ + role: 'assistant', + content: errorMsg, + timestamp: Date.now(), + }); + break; + } + + case 'conversationLoaded': { + const conversation = message.data as Conversation; + handlers.messageHandling.setMessages(conversation.messages); + break; + } + + case 'message': { + const msg = message.data as { + role?: 'user' | 'assistant' | 'thinking'; + content?: string; + timestamp?: number; + }; + handlers.messageHandling.addMessage( + msg as unknown as Parameters< + typeof handlers.messageHandling.addMessage + >[0], + ); + // Robustness: if an assistant message arrives outside the normal stream + // pipeline (no explicit streamEnd), ensure we clear streaming/waiting states + if (msg.role === 'assistant') { + try { + handlers.messageHandling.endStreaming(); + } catch (_error) { + // no-op: stream might not have been started + console.warn('[PanelManager] Failed to end streaming:', _error); + } + // Important: Do NOT blindly clear the waiting message if there are + // still active tool calls running. We keep the waiting indicator + // tied to tool-call lifecycle instead. + if (activeExecToolCallsRef.current.size === 0) { + try { + handlers.messageHandling.clearWaitingForResponse(); + } catch (_error) { + // no-op: already cleared + console.warn( + '[PanelManager] Failed to clear waiting for response:', + _error, + ); + } + } + } + break; + } + + case 'streamStart': + handlers.messageHandling.startStreaming( + (message.data as { timestamp?: number } | undefined)?.timestamp, + ); + break; + + case 'streamChunk': { + handlers.messageHandling.appendStreamChunk(message.data.chunk); + break; + } + + case 'thoughtChunk': { + const chunk = message.data.content || message.data.chunk || ''; + handlers.messageHandling.appendThinkingChunk(chunk); + break; + } + + case 'streamEnd': { + // Always end local streaming state and collapse any thoughts + 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. + try { + const reason = ( + (message.data as { reason?: string } | undefined)?.reason || '' + ).toLowerCase(); + if (reason === 'user_cancelled') { + activeExecToolCallsRef.current.clear(); + handlers.messageHandling.clearWaitingForResponse(); + break; + } + } catch (_error) { + // best-effort + } + + // 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. + if (activeExecToolCallsRef.current.size === 0) { + handlers.messageHandling.clearWaitingForResponse(); + } + break; + } + + case 'error': + handlers.messageHandling.clearWaitingForResponse(); + break; + + case 'permissionRequest': { + handlers.handlePermissionRequest(message.data); + + const permToolCall = message.data?.toolCall as { + toolCallId?: string; + kind?: string; + title?: string; + status?: string; + content?: unknown[]; + locations?: Array<{ path: string; line?: number | null }>; + }; + + if (permToolCall?.toolCallId) { + // Infer kind more robustly for permission preview: + // - If content contains a diff entry, force 'edit' so the EditToolCall can handle it properly + // - Else try title-based hints; fall back to provided kind or 'execute' + let kind = permToolCall.kind || 'execute'; + const contentArr = (permToolCall.content as unknown[]) || []; + const hasDiff = Array.isArray(contentArr) + ? contentArr.some( + (c: unknown) => + !!c && + typeof c === 'object' && + (c as { type?: string }).type === 'diff', + ) + : false; + if (hasDiff) { + kind = 'edit'; + + // Auto-open diff view for edit operations with diff content + // This replaces the useEffect auto-trigger in EditToolCall component + const diffContent = contentArr.find( + (c: unknown) => + !!c && + typeof c === 'object' && + (c as { type?: string }).type === 'diff', + ) as + | { path?: string; oldText?: string; newText?: string } + | undefined; + + if ( + diffContent?.path && + diffContent?.oldText !== undefined && + diffContent?.newText !== undefined + ) { + vscode.postMessage({ + type: 'openDiff', + data: { + path: diffContent.path, + oldText: diffContent.oldText, + newText: diffContent.newText, + }, + }); + } + } else if (permToolCall.title) { + const title = permToolCall.title.toLowerCase(); + if (title.includes('touch') || title.includes('echo')) { + kind = 'execute'; + } else if (title.includes('read') || title.includes('cat')) { + kind = 'read'; + } else if (title.includes('write') || title.includes('edit')) { + kind = 'edit'; + } + } + + const normalizedStatus = ( + permToolCall.status === 'pending' || + permToolCall.status === 'in_progress' || + permToolCall.status === 'completed' || + permToolCall.status === 'failed' + ? permToolCall.status + : 'pending' + ) as ToolCallUpdate['status']; + + handlers.handleToolCallUpdate({ + type: 'tool_call', + toolCallId: permToolCall.toolCallId, + kind, + title: permToolCall.title, + status: normalizedStatus, + content: permToolCall.content as ToolCallUpdate['content'], + locations: permToolCall.locations, + }); + + // Split assistant stream so subsequent chunks start a new assistant message + handlers.messageHandling.breakAssistantSegment(); + } + break; + } + + case 'permissionResolved': { + // Extension proactively resolved a pending permission; close drawer. + try { + handlers.handlePermissionRequest(null); + } catch (_error) { + console.warn( + '[useWebViewMessages] failed to close permission UI:', + _error, + ); + } + break; + } + + case 'plan': + if (message.data.entries && Array.isArray(message.data.entries)) { + const entries = message.data.entries as PlanEntry[]; + handlers.setPlanEntries(entries); + + // Generate new snapshot text + const lines = buildPlanLines(entries); + const text = lines.join('\n'); + const prev = lastPlanSnapshotRef.current; + + // 1) Identical -> Skip + if (prev && prev.text === text) { + break; + } + + try { + const ts = Date.now(); + + // 2) Supplement or status update -> Merge to previous (use tool_call_update to override content) + if (prev && isSupplementOf(prev.lines, lines)) { + handlers.handleToolCallUpdate({ + type: 'tool_call_update', + toolCallId: prev.id, + kind: 'todo_write', + title: 'Updated Plan', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text }, + }, + ], + timestamp: ts, + }); + lastPlanSnapshotRef.current = { id: prev.id, text, lines }; + } else { + // 3) Other cases -> Add a new history card + const toolCallId = `plan-snapshot-${ts}`; + handlers.handleToolCallUpdate({ + type: 'tool_call', + toolCallId, + kind: 'todo_write', + title: 'Updated Plan', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text }, + }, + ], + timestamp: ts, + }); + lastPlanSnapshotRef.current = { id: toolCallId, text, lines }; + } + + // Split assistant message segments, keep rendering blocks independent + handlers.messageHandling.breakAssistantSegment?.(); + } catch (_error) { + console.warn( + '[useWebViewMessages] failed to push/merge plan snapshot toolcall:', + _error, + ); + } + } + break; + + case 'toolCall': + case 'toolCallUpdate': { + const toolCallData = message.data; + if (toolCallData.sessionUpdate && !toolCallData.type) { + toolCallData.type = toolCallData.sessionUpdate; + } + handlers.handleToolCallUpdate(toolCallData); + + // Split assistant stream + const status = (toolCallData.status || '').toString(); + const isStart = toolCallData.type === 'tool_call'; + const isFinalUpdate = + toolCallData.type === 'tool_call_update' && + (status === 'completed' || status === 'failed'); + if (isStart || isFinalUpdate) { + handlers.messageHandling.breakAssistantSegment(); + } + + // While long-running tools (e.g., execute/bash/command) are in progress, + // surface a lightweight loading indicator and expose the Stop button. + try { + const kind = (toolCallData.kind || '').toString().toLowerCase(); + const isExec = + kind === 'execute' || kind === 'bash' || kind === 'command'; + + if (isExec) { + const id = (toolCallData.toolCallId || '').toString(); + + // Maintain the active set by status + if (status === 'pending' || status === 'in_progress') { + activeExecToolCallsRef.current.add(id); + + // Build a helpful hint from rawInput + const rawInput = toolCallData.rawInput; + let cmd = ''; + if (typeof rawInput === 'string') { + cmd = rawInput; + } else if (rawInput && typeof rawInput === 'object') { + const maybe = rawInput as { command?: string }; + cmd = maybe.command || ''; + } + const hint = cmd ? `Running: ${cmd}` : 'Running command...'; + handlers.messageHandling.setWaitingForResponse(hint); + } 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(); + } + } + } catch (_error) { + // Best-effort UI hint; ignore errors + } + break; + } + + case 'qwenSessionList': { + const sessions = + (message.data.sessions as Array>) || []; + const append = Boolean(message.data.append); + const nextCursor = message.data.nextCursor as number | undefined; + const hasMore = Boolean(message.data.hasMore); + + handlers.sessionManagement.setQwenSessions( + (prev: Array>) => + append ? [...prev, ...sessions] : sessions, + ); + handlers.sessionManagement.setNextCursor(nextCursor); + handlers.sessionManagement.setHasMore(hasMore); + handlers.sessionManagement.setIsLoading(false); + if ( + handlers.sessionManagement.currentSessionId && + sessions.length > 0 + ) { + const currentSession = sessions.find( + (s: Record) => + (s.id as string) === + handlers.sessionManagement.currentSessionId || + (s.sessionId as string) === + handlers.sessionManagement.currentSessionId, + ); + if (currentSession) { + const title = + (currentSession.title as string) || + (currentSession.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + } + } + break; + } + + case 'qwenSessionSwitched': + handlers.sessionManagement.setShowSessionSelector(false); + if (message.data.sessionId) { + handlers.sessionManagement.setCurrentSessionId( + message.data.sessionId as string, + ); + } + if (message.data.session) { + const session = message.data.session as Record; + const title = + (session.title as string) || + (session.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + // Update the VS Code webview tab title as well + vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); + } + if (message.data.messages) { + handlers.messageHandling.setMessages(message.data.messages); + } else { + handlers.messageHandling.clearMessages(); + } + + // Clear any waiting message that might be displayed from previous session + handlers.messageHandling.clearWaitingForResponse(); + + // Clear active tool calls tracking + activeExecToolCallsRef.current.clear(); + + // Clear and restore tool calls if provided in session data + handlers.clearToolCalls(); + if (message.data.toolCalls && Array.isArray(message.data.toolCalls)) { + message.data.toolCalls.forEach((toolCall: unknown) => { + if (toolCall && typeof toolCall === 'object') { + handlers.handleToolCallUpdate(toolCall as ToolCallUpdate); + } + }); + } + + // Restore plan entries if provided + if ( + message.data.planEntries && + Array.isArray(message.data.planEntries) + ) { + handlers.setPlanEntries(message.data.planEntries); + } else { + handlers.setPlanEntries([]); + } + lastPlanSnapshotRef.current = null; + break; + + case 'conversationCleared': + handlers.messageHandling.clearMessages(); + handlers.clearToolCalls(); + handlers.sessionManagement.setCurrentSessionId(null); + handlers.sessionManagement.setCurrentSessionTitle( + 'Past Conversations', + ); + // Reset the VS Code tab title to default label + vscode.postMessage({ + type: 'updatePanelTitle', + data: { title: 'Qwen Code' }, + }); + lastPlanSnapshotRef.current = null; + break; + + case 'sessionTitleUpdated': { + const sessionId = message.data?.sessionId as string; + const title = message.data?.title as string; + if (sessionId && title) { + handlers.sessionManagement.setCurrentSessionId(sessionId); + handlers.sessionManagement.setCurrentSessionTitle(title); + // Ask extension host to reflect this title in the tab label + vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); + } + break; + } + + case 'activeEditorChanged': { + const fileName = message.data?.fileName as string | null; + const filePath = message.data?.filePath as string | null; + const selection = message.data?.selection as { + startLine: number; + endLine: number; + } | null; + handlers.fileContext.setActiveFileName(fileName); + handlers.fileContext.setActiveFilePath(filePath); + handlers.fileContext.setActiveSelection(selection); + break; + } + + case 'fileAttached': { + const attachment = message.data as { + id: string; + type: string; + name: string; + value: string; + }; + + handlers.fileContext.addFileReference( + attachment.name, + attachment.value, + ); + + if (inputFieldRef.current) { + const currentText = inputFieldRef.current.textContent || ''; + const newText = currentText + ? `${currentText} @${attachment.name} ` + : `@${attachment.name} `; + inputFieldRef.current.textContent = newText; + setInputText(newText); + + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(inputFieldRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + } + break; + } + + case 'workspaceFiles': { + const files = message.data?.files as Array<{ + id: string; + label: string; + description: string; + path: string; + }>; + if (files) { + console.log('[WebView] Received workspaceFiles:', files.length); + handlers.fileContext.setWorkspaceFiles(files); + } + break; + } + + case 'saveSessionResponse': { + handlers.sessionManagement.handleSaveSessionResponse(message.data); + break; + } + + case 'cancelStreaming': + // Handle cancel streaming request from webview + handlers.messageHandling.endStreaming(); + handlers.messageHandling.clearWaitingForResponse(); + // Add interrupted message + handlers.messageHandling.addMessage({ + role: 'assistant', + content: 'Interrupted', + timestamp: Date.now(), + }); + break; + + default: + break; + } + }, + [inputFieldRef, setInputText, vscode, setEditMode], + ); + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [handleMessage]); +}; diff --git a/packages/vscode-ide-companion/src/webview/index.tsx b/packages/vscode-ide-companion/src/webview/index.tsx new file mode 100644 index 00000000..547dc3fc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/index.tsx @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import ReactDOM from 'react-dom/client'; +import { App } from './App.js'; + +// eslint-disable-next-line import/no-internal-modules +import './styles/tailwind.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/App.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/styles.css'; + +const container = document.getElementById('root'); +if (container) { + const root = ReactDOM.createRoot(container); + root.render(); +} diff --git a/packages/vscode-ide-companion/src/webview/styles/App.css b/packages/vscode-ide-companion/src/webview/styles/App.css new file mode 100644 index 00000000..e4ce12ea --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/App.css @@ -0,0 +1,602 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* =========================== + CSS Variables (Root Level) + =========================== */ +:root { + /* Qwen Brand Colors */ + --app-qwen-theme: #615fff; + --app-qwen-clay-button-orange: #4f46e5; + --app-qwen-ivory: #f5f5ff; + --app-qwen-slate: #141420; + --app-qwen-green: #6bcf7f; + + /* Spacing */ + --app-spacing-small: 4px; + --app-spacing-medium: 8px; + --app-spacing-large: 12px; + --app-spacing-xlarge: 16px; + + /* Border Radius */ + --corner-radius-small: 4px; + --corner-radius-medium: 6px; + --corner-radius-large: 8px; + + /* Typography */ + --app-monospace-font-family: var(--vscode-editor-font-family, monospace); + --app-monospace-font-size: var(--vscode-editor-font-size, 12px); + + /* Foreground & Background */ + --app-primary-foreground: var(--vscode-foreground); + --app-primary-background: var(--vscode-sideBar-background); + --app-primary-border-color: var(--vscode-sideBarActivityBarTop-border); + --app-secondary-foreground: var(--vscode-descriptionForeground); + + /* Input Colors */ + --app-input-foreground: var(--vscode-input-foreground); + --app-input-background: var(--vscode-input-background); + --app-input-border: var(--vscode-inlineChatInput-border); + --app-input-active-border: var(--vscode-inputOption-activeBorder); + --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); + --app-input-secondary-background: var(--vscode-menu-background); + /* Input Highlight (focus ring/border) */ + --app-input-highlight: var(--app-qwen-theme); + + /* Code Highlighting */ + --app-code-background: var( + --vscode-textCodeBlock-background, + rgba(0, 0, 0, 0.05) + ); + --app-link-foreground: var(--vscode-textLink-foreground, #007acc); + --app-link-active-foreground: var( + --vscode-textLink-activeForeground, + #005a9e + ); + + /* List Styles */ + --app-list-hover-background: var(--vscode-list-hoverBackground); + --app-list-active-background: var(--vscode-list-activeSelectionBackground); + --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); + + /* Buttons */ + --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); + --app-button-foreground: var(--vscode-button-foreground); + --app-button-background: var(--vscode-button-background); + --app-button-hover-background: var(--vscode-button-hoverBackground); + + /* Border Transparency */ + --app-transparent-inner-border: rgba(255, 255, 255, 0.1); + + /* Header */ + --app-header-background: var(--vscode-sideBar-background); + + /* List Styles*/ + --app-list-padding: 0px; + --app-list-item-padding: 4px 8px; + --app-list-border-color: transparent; + --app-list-border-radius: 4px; + --app-list-gap: 2px; + + /* Menu Colors*/ + --app-menu-background: var(--vscode-menu-background); + --app-menu-border: var(--vscode-menu-border); + --app-menu-foreground: var(--vscode-menu-foreground); + --app-menu-selection-background: var(--vscode-menu-selectionBackground); + --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); + + /* Modal */ + --app-modal-background: rgba(0, 0, 0, 0.75); + + /* Widget */ + --app-widget-border: var(--vscode-editorWidget-border); + --app-widget-shadow: var(--vscode-widget-shadow); +} + +/* Light Theme Overrides */ +.vscode-light { + --app-transparent-inner-border: rgba(0, 0, 0, 0.07); + /* Slightly different brand shade in light theme for better contrast */ + --app-input-highlight: var(--app-qwen-clay-button-orange); +} + +/* Icon SVG styles */ +.icon-svg { + display: block; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--vscode-chat-font-family, var(--vscode-font-family)); + background-color: var(--app-primary-background); + color: var(--app-primary-foreground); + overflow: hidden; + font-size: var(--vscode-chat-font-size, 13px); + padding: 0; +} + +/* Ensure tool call containers keep a consistent left indent even if Tailwind utilities are purged */ +.toolcall-container { + /* Consistent indent for tool call blocks */ + padding-left: 30px; +} + +.toolcall-card { + /* Consistent indent for card-style tool calls */ + padding-left: 30px; +} + +button { + color: var(--app-primary-foreground); + font-family: var(--vscode-chat-font-family); + font-size: var(--vscode-chat-font-size, 13px); +} + +/* =========================== + Main Chat Container + =========================== */ +.chat-container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + background-color: var(--app-primary-background); + color: var(--app-primary-foreground); +} + +/* Message list container: prevent browser scroll anchoring from fighting our manual pin-to-bottom logic */ +.chat-messages > * { + /* Disable overflow anchoring on individual items so the UA doesn't auto-adjust scroll */ + overflow-anchor: none; +} + +/* =========================== + Animations (used by message components) + =========================== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +@keyframes typingPulse { + 0%, + 60%, + 100% { + transform: scale(0.7); + opacity: 0.6; + } + 30% { + transform: scale(1); + opacity: 1; + } +} + +/* =========================== + Input Form Styles + =========================== */ +.input-form { + display: flex; + background-color: var(--app-primary-background); + border-top: 1px solid var(--app-primary-border-color); +} + +.input-field { + flex: 1; + padding: 10px 12px; + background-color: var(--app-input-background); + color: var(--app-input-foreground); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + font-size: var(--vscode-chat-font-size, 13px); + font-family: var(--vscode-chat-font-family); + outline: none; + line-height: 1.5; +} + +.input-field:focus { + border-color: var(--app-qwen-theme); +} + +.input-field:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input-field::placeholder { + color: var(--app-input-placeholder-foreground); +} + +.send-button { + padding: 10px 20px; + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + border: none; + border-radius: var(--corner-radius-small); + font-size: var(--vscode-chat-font-size, 13px); + font-weight: 500; + cursor: pointer; + transition: filter 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.send-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.send-button:active:not(:disabled) { + filter: brightness(0.9); +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Animation for in-progress status (used by pseudo bullets and spinners) */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.code-block { + font-family: var(--app-monospace-font-family); + font-size: var(--app-monospace-font-size); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + padding: var(--app-spacing-medium); + overflow-x: auto; + margin: 4px 0 0 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +/* =========================== + Diff Display Styles + =========================== */ +.diff-display-container { + margin: 8px 0; + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-medium); + overflow: hidden; +} + +.diff-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--app-input-secondary-background); + border-bottom: 1px solid var(--app-input-border); +} + +.diff-file-path { + font-family: var(--app-monospace-font-family); + font-size: 13px; + color: var(--app-primary-foreground); +} + +.open-diff-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + color: var(--app-primary-foreground); + cursor: pointer; + font-size: 12px; + transition: background-color 0.15s; +} + +.open-diff-button:hover { + background: var(--app-ghost-button-hover-background); +} + +.open-diff-button svg { + width: 16px; + height: 16px; +} + +.diff-section { + margin: 0; +} + +.diff-label { + padding: 8px 12px; + background: var(--app-primary-background); + border-bottom: 1px solid var(--app-input-border); + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + text-transform: uppercase; +} + +.diff-section .code-block { + border: none; + border-radius: 0; + margin: 0; + max-height: none; /* Remove height limit for diffs */ + overflow-y: visible; +} + +.diff-section .code-content { + display: block; +} + +/* =========================== + Permission Request Card Styles + =========================== */ +.permission-request-card { + background: var(--app-input-background); + border: 1px solid var(--app-qwen-theme); + border-radius: var(--corner-radius-medium); + margin: var(--app-spacing-medium) 0; + margin-bottom: var(--app-spacing-xlarge); + overflow: visible; + animation: fadeIn 0.2s ease-in; +} + +.permission-card-body { + padding: var(--app-spacing-large); + min-height: fit-content; + height: auto; +} + +.permission-header { + display: flex; + align-items: center; + gap: var(--app-spacing-large); + margin-bottom: var(--app-spacing-large); +} + +.permission-icon-wrapper { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(97, 95, 255, 0.1); + border-radius: var(--corner-radius-medium); + flex-shrink: 0; +} + +.permission-icon { + font-size: 20px; +} + +.permission-info { + flex: 1; + min-width: 0; +} + +.permission-title { + font-weight: 600; + color: var(--app-primary-foreground); + margin-bottom: 2px; +} + +.permission-subtitle { + font-size: 12px; + color: var(--app-secondary-foreground); +} + +.permission-command-section { + margin-bottom: var(--app-spacing-large); +} + +.permission-command-label { + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + margin-bottom: var(--app-spacing-small); + text-transform: uppercase; +} + +.permission-command-code { + display: block; + font-family: var(--app-monospace-font-family); + font-size: var(--app-monospace-font-size); + color: var(--app-primary-foreground); + background: var(--app-primary-background); + padding: var(--app-spacing-medium); + border-radius: var(--corner-radius-small); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.permission-locations-section { + margin-bottom: var(--app-spacing-large); +} + +.permission-locations-label { + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + margin-bottom: var(--app-spacing-small); + text-transform: uppercase; +} + +.permission-location-item { + display: flex; + align-items: center; + gap: var(--app-spacing-small); + padding: var(--app-spacing-small) 0; + font-size: 12px; +} + +.permission-location-icon { + flex-shrink: 0; +} + +.permission-location-path { + color: var(--app-primary-foreground); + font-family: var(--app-monospace-font-family); +} + +.permission-location-line { + color: var(--app-secondary-foreground); +} + +.permission-options-section { + margin-top: var(--app-spacing-large); +} + +.permission-options-label { + font-size: 12px; + font-weight: 500; + color: var(--app-primary-foreground); + margin-bottom: var(--app-spacing-medium); +} + +.permission-options-list { + display: flex; + flex-direction: column; + gap: var(--app-spacing-small); +} + +.permission-option { + display: flex; + align-items: center; + gap: var(--app-spacing-medium); + padding: var(--app-spacing-medium) var(--app-spacing-large); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + cursor: pointer; + transition: all 0.15s ease; +} + +.permission-option:hover { + background: var(--app-list-hover-background); + border-color: var(--app-input-active-border); +} + +.permission-option.selected { + border-color: var(--app-qwen-theme); + background: rgba(97, 95, 255, 0.1); +} + +.permission-radio { + flex-shrink: 0; +} + +.permission-option-content { + display: flex; + align-items: center; + gap: var(--app-spacing-small); + flex: 1; +} + +.permission-option-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + background-color: var(--app-list-hover-background); + border-radius: 4px; + margin-right: 4px; +} + +.permission-option.selected .permission-option-number { + color: var(--app-qwen-ivory); + background-color: var(--app-qwen-theme); +} + +.permission-always-badge { + font-size: 12px; +} + +.permission-no-options { + text-align: center; + padding: var(--app-spacing-large); + color: var(--app-secondary-foreground); +} + +.permission-actions { + margin-top: var(--app-spacing-large); + display: flex; + justify-content: flex-end; +} + +.permission-confirm-button { + padding: var(--app-spacing-medium) var(--app-spacing-xlarge); + background: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + border: none; + border-radius: var(--corner-radius-small); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: filter 0.15s ease; +} + +.permission-confirm-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.permission-confirm-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.permission-success { + display: flex; + align-items: center; + justify-content: center; + gap: var(--app-spacing-medium); + padding: var(--app-spacing-large); + background: rgba(76, 175, 80, 0.1); + border-radius: var(--corner-radius-small); + margin-top: var(--app-spacing-large); +} + +.permission-success-icon { + color: #4caf50; + font-weight: bold; +} + +.permission-success-text { + color: #4caf50; + font-size: 13px; +} diff --git a/packages/vscode-ide-companion/src/webview/styles/styles.css b/packages/vscode-ide-companion/src/webview/styles/styles.css new file mode 100644 index 00000000..4c3db053 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/styles.css @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* Import component styles */ +@import '../components/messages/Assistant/AssistantMessage.css'; +@import './timeline.css'; +@import '../components/messages/MarkdownRenderer/MarkdownRenderer.css'; + +/* =========================== + CSS Variables + =========================== */ +:root { + /* Colors */ + --app-primary-foreground: var(--vscode-foreground); + --app-secondary-foreground: var(--vscode-descriptionForeground); + --app-primary-border-color: var(--vscode-panel-border); + --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); + + /* Buttons */ + --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); + + /* Border Radius */ + --corner-radius-small: 6px; + + /* Header */ + --app-header-background: var(--vscode-sideBar-background); + + /* List Styles */ + --app-list-padding: 0px; + --app-list-item-padding: 4px 8px; + --app-list-border-color: transparent; + --app-list-border-radius: 4px; + --app-list-hover-background: var(--vscode-list-hoverBackground); + --app-list-active-background: var(--vscode-list-activeSelectionBackground); + --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); + --app-list-gap: 2px; + + /* Menu Styles */ + --app-menu-background: var(--vscode-menu-background); + --app-menu-border: var(--vscode-menu-border); + --app-menu-foreground: var(--vscode-menu-foreground); + --app-menu-selection-background: var(--vscode-menu-selectionBackground); + --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); + + /* Tool Call Styles */ + --app-tool-background: var(--vscode-editor-background); + --app-code-background: var(--vscode-textCodeBlock-background); + + /* Warning/Error Styles */ + --app-warning-background: var( + --vscode-editorWarning-background, + rgba(255, 204, 0, 0.1) + ); + --app-warning-border: var(--vscode-editorWarning-foreground, #ffcc00); + --app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00); +} diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css new file mode 100644 index 00000000..4c4b5e08 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* =========================== + Reusable Component Classes + =========================== */ +@layer components { + .btn-ghost { + @apply bg-transparent border border-transparent rounded cursor-pointer outline-none transition-colors duration-200; + color: var(--app-primary-foreground); + font-size: var(--vscode-chat-font-size, 13px); + border-radius: 4px; + } + + .btn-ghost:hover, + .btn-ghost:focus { + background: var(--app-ghost-button-hover-background); + } + + .btn-sm { + @apply p-small; + } + + .btn-md { + @apply py-small px-medium; + } + + .icon-sm { + @apply w-4 h-4; + } + + /* Composer: root container anchored to bottom*/ + .composer-root { + @apply absolute bottom-4 left-4 right-4 flex flex-col z-20; + } + + /* Composer: form wrapper */ + .composer-form { + @apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200; + background: var(--app-input-secondary-background); + border-color: var(--app-input-border); + color: var(--app-input-foreground); + } + .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%); + } + + /* Composer: input editable area */ + .composer-input { + /* Use plain CSS for font-family inheritance; Tailwind has no `font-inherit` utility */ + @apply flex-1 self-stretch py-2.5 px-3.5 outline-none overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-0 rounded-none overflow-x-hidden break-words whitespace-pre-wrap; + font-family: inherit; + font-size: var(--vscode-chat-font-size, 13px); + color: var(--app-input-foreground); + } + /* Show placeholder when truly empty OR when flagged as empty via data attribute. + 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 { + content: attr(data-placeholder); + color: var(--app-input-placeholder-foreground); + pointer-events: none; + position: absolute; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - 28px); + } + .composer-input:focus { + outline: none; + } + .composer-input:disabled, + .composer-input[contenteditable='false'] { + color: #999; + cursor: not-allowed; + } + + /* Composer: actions row (more compact) */ + .composer-actions { + @apply flex items-center gap-1 min-w-0 z-[1]; + padding: 5px; + color: var(--app-secondary-foreground); + border-top: 0.5px solid var(--app-input-border); + } + + /* Text button (icon + label) */ + .btn-text-compact { + @apply inline-flex items-center gap-1 px-1 py-0.5 rounded-[2px] cursor-pointer appearance-none bg-transparent border-0 min-w-0 shrink text-[0.85em] transition-colors duration-150; + color: var(--app-secondary-foreground); + } + .btn-text-compact--primary { + color: var(--app-secondary-foreground); + /* color: var(--app-primary-foreground); */ + } + .btn-text-compact:hover { + background-color: var(--app-ghost-button-hover-background); + } + .btn-text-compact:active:not(:disabled) { + filter: brightness(1.1); + } + .btn-text-compact > svg { + height: 1em; + width: 1em; + flex-shrink: 0; + } + .btn-text-compact > span { + display: inline-block; + min-width: 0; + max-width: 200px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + vertical-align: middle; + } + + @media screen and (max-width: 300px) { + .btn-text-compact > svg { + display: none; + } + } + + /* Icon-only button, compact square (26x26) */ + .btn-icon-compact { + @apply inline-flex items-center justify-center w-[26px] h-[26px] p-0 rounded-small bg-transparent border border-transparent cursor-pointer shrink-0 transition-all duration-150; + color: var(--app-secondary-foreground); + } + .btn-icon-compact:hover { + background-color: var(--app-ghost-button-hover-background); + } + .btn-icon-compact > svg { + @apply w-4 h-4; + } + /* Active/primary state for icon button (e.g., Thinking on) */ + .btn-icon-compact--active { + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + } + .btn-icon-compact--active > svg { + stroke: var(--app-qwen-ivory); + fill: var(--app-qwen-ivory); + } + + .composer-overlay { + @apply absolute inset-0 rounded-large z-0; + background: var(--app-input-background); + } + + /* Optional: send button variant */ + .btn-send-compact { + @apply btn-icon-compact ml-auto hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed; + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + } + + /* + * File path styling inside tool call content + * Applies to: .toolcall-content-wrapper .file-link-path + * - Use monospace editor font + * - Slightly smaller size + * - Link color + * - Tighten top alignment and allow aggressive breaking for long paths + */ + .toolcall-content-wrapper .file-link-path { + /* Tailwind utilities where possible */ + @apply text-[0.85em] pt-px break-all min-w-0; + /* Not covered by Tailwind defaults: use CSS vars / properties */ + font-family: var(--app-monospace-font-family); + color: var(--app-link-color); + overflow-wrap: anywhere; + } +} + +/* =========================== + Utilities + =========================== */ +@layer utilities { + /* Multi-line clamp with ellipsis (Chromium-based webview supported) */ + .q-line-clamp-3 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + } +} diff --git a/packages/vscode-ide-companion/src/webview/styles/timeline.css b/packages/vscode-ide-companion/src/webview/styles/timeline.css new file mode 100644 index 00000000..25d5cc85 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/timeline.css @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Unified timeline styles for tool calls and messages + */ + +/* ========================================== + ToolCallContainer timeline styles + ========================================== */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* ToolCallContainer timeline connector */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + AssistantMessage timeline styles + ========================================== */ +.assistant-message-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* AssistantMessage timeline connector */ +.assistant-message-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.assistant-message-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.assistant-message-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + Custom timeline styles for qwen-message message-item elements + ========================================== */ + +/* Default connector style - creates full-height connectors for all AI message items */ +.qwen-message.message-item:not(.user-message-container)::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); + z-index: 0; +} + +/* 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, +/* If the previous sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, or card-style tool calls), also treat as a new group start */ +.chat-messages > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container)::after { + top: 15px; +} + +/* Handle the end of each AI message sequence */ +/* When the next sibling is a user message */ +.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after, +/* Or when the next sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, card-style tool calls, etc.) */ +.qwen-message.message-item:not(.user-message-container):has(+ :not(.qwen-message.message-item))::after, +/* When it's truly the last child element of the parent container */ +.qwen-message.message-item:not(.user-message-container):last-child::after { + /* Note: When setting both top and bottom, the height is (container height - top - bottom). + * Here we expect "15px spacing at the bottom", so bottom should be 15px (not calc(100% - 15px)). */ + top: 0; + bottom: calc(100% - 15px); +} + +.user-message-container:first-child { + margin-top: 0; +} + +.message-item { + padding: 8px 0; + width: 100%; + align-items: flex-start; + padding-left: 30px; + user-select: text; + position: relative; + padding-top: 8px; + padding-bottom: 8px; +} \ No newline at end of file diff --git a/packages/vscode-ide-companion/src/webview/utils/diffStats.ts b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts new file mode 100644 index 00000000..78918821 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Diff statistics calculation tool + */ + +/** + * Diff statistics + */ +export interface DiffStats { + /** Number of added lines */ + added: number; + /** Number of removed lines */ + removed: number; + /** Number of changed lines (estimated value) */ + changed: number; + /** Total number of changed lines */ + total: number; +} + +/** + * Calculate diff statistics between two texts + * + * Using a simple line comparison algorithm (avoiding heavy-weight diff libraries) + * Algorithm explanation: + * 1. Split text by lines + * 2. Compare set differences of lines + * 3. Estimate changed lines (lines that appear in both added and removed) + * + * @param oldText Old text content + * @param newText New text content + * @returns Diff statistics + * + * @example + * ```typescript + * const stats = calculateDiffStats( + * "line1\nline2\nline3", + * "line1\nline2-modified\nline4" + * ); + * // { added: 2, removed: 2, changed: 1, total: 3 } + * ``` + */ +export function calculateDiffStats( + oldText: string | null | undefined, + newText: string | undefined, +): DiffStats { + // Handle null values + const oldContent = oldText || ''; + const newContent = newText || ''; + + // Split by lines + const oldLines = oldContent.split('\n').filter((line) => line.trim() !== ''); + const newLines = newContent.split('\n').filter((line) => line.trim() !== ''); + + // If one of them is empty, calculate directly + if (oldLines.length === 0) { + return { + added: newLines.length, + removed: 0, + changed: 0, + total: newLines.length, + }; + } + + if (newLines.length === 0) { + return { + added: 0, + removed: oldLines.length, + changed: 0, + total: oldLines.length, + }; + } + + // Use Set for fast lookup + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + + // Calculate added: lines in new but not in old + const addedLines = newLines.filter((line) => !oldSet.has(line)); + + // Calculate removed: lines in old but not in new + const removedLines = oldLines.filter((line) => !newSet.has(line)); + + // Estimate changes: take the minimum value (because changed lines are both deleted and added) + // This is a simplified estimation, actual diff algorithms would be more precise + const estimatedChanged = Math.min(addedLines.length, removedLines.length); + + const added = addedLines.length - estimatedChanged; + const removed = removedLines.length - estimatedChanged; + const changed = estimatedChanged; + + return { + added, + removed, + changed, + total: added + removed + changed, + }; +} + +/** + * Format diff statistics as human-readable text + * + * @param stats Diff statistics + * @returns Formatted text, e.g. "+5 -3 ~2" + * + * @example + * ```typescript + * formatDiffStats({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 -3 ~2" + * ``` + */ +export function formatDiffStats(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed}`); + } + + return parts.join(' ') || 'No changes'; +} + +/** + * Format detailed diff statistics + * + * @param stats Diff statistics + * @returns Detailed description text + * + * @example + * ```typescript + * formatDiffStatsDetailed({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 lines, -3 lines, ~2 lines" + * ``` + */ +export function formatDiffStatsDetailed(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added} ${stats.added === 1 ? 'line' : 'lines'}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed} ${stats.removed === 1 ? 'line' : 'lines'}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed} ${stats.changed === 1 ? 'line' : 'lines'}`); + } + + return parts.join(', ') || 'No changes'; +} diff --git a/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts b/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts new file mode 100644 index 00000000..dac37cf3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared utilities for handling diff operations in the webview + */ + +import type { VSCodeAPI } from '../hooks/useVSCode.js'; + +/** + * Handle opening a diff view for a file + * @param vscode Webview API instance + * @param path File path + * @param oldText Original content (left side) + * @param newText New content (right side) + */ +export const handleOpenDiff = ( + vscode: VSCodeAPI, + path: string | undefined, + oldText: string | null | undefined, + newText: string | undefined, +): void => { + if (path) { + vscode.postMessage({ + type: 'openDiff', + data: { path, oldText: oldText || '', newText: newText || '' }, + }); + } +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts b/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts new file mode 100644 index 00000000..d55d4e14 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Extend Window interface to include __EXTENSION_URI__ +declare global { + interface Window { + __EXTENSION_URI__?: string; + } +} + +/** + * Get the extension URI from the body data attribute or window global + * @returns Extension URI or undefined if not found + */ +function getExtensionUri(): string | undefined { + // First try to get from window (for backwards compatibility) + if (window.__EXTENSION_URI__) { + return window.__EXTENSION_URI__; + } + + // Then try to get from body data attribute (CSP-compliant method) + const bodyUri = document.body?.getAttribute('data-extension-uri'); + if (bodyUri) { + // Cache it in window for future use + window.__EXTENSION_URI__ = bodyUri; + return bodyUri; + } + + return undefined; +} + +/** + * Validate if URL is a secure VS Code webview resource URL + * Prevent XSS attacks + * + * @param url - URL to validate + * @returns Whether it is a secure URL + */ +function isValidWebviewUrl(url: string): boolean { + try { + // Valid protocols for VS Code webview resource URLs + const allowedProtocols = [ + 'vscode-webview-resource:', + 'https-vscode-webview-resource:', + 'vscode-file:', + 'https:', + ]; + + // Check if it starts with a valid protocol + return allowedProtocols.some((protocol) => url.startsWith(protocol)); + } catch { + return false; + } +} + +/** + * Generate a resource URL for webview access + * Similar to the pattern used in other VSCode extensions + * + * @param relativePath - Relative path from extension root (e.g., 'assets/icon.png') + * @returns Full webview-accessible URL (empty string if validation fails) + * + * @example + * ```tsx + * + * ``` + */ +export function generateResourceUrl(relativePath: string): string { + const extensionUri = getExtensionUri(); + + if (!extensionUri) { + console.warn('[resourceUrl] Extension URI not found in window or body'); + return ''; + } + + // Validate if extensionUri is a secure URL + if (!isValidWebviewUrl(extensionUri)) { + console.error( + '[resourceUrl] Invalid extension URI - possible security risk:', + extensionUri, + ); + return ''; + } + + // Remove leading slash if present + const cleanPath = relativePath.startsWith('/') + ? relativePath.slice(1) + : relativePath; + + // Ensure extension URI has trailing slash + const baseUri = extensionUri.endsWith('/') + ? extensionUri + : `${extensionUri}/`; + + const fullUrl = `${baseUri}${cleanPath}`; + + // Validate if the final generated URL is secure + if (!isValidWebviewUrl(fullUrl)) { + console.error('[resourceUrl] Generated URL failed validation:', fullUrl); + return ''; + } + + return fullUrl; +} + +/** + * Shorthand for generating icon URLs + * @param iconPath - Path relative to assets directory + */ +export function generateIconUrl(iconPath: string): string { + return generateResourceUrl(`assets/${iconPath}`); +} diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts new file mode 100644 index 00000000..31326cc6 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface SessionGroup { + label: string; + sessions: Array>; +} + +/** + * Group sessions by date + * + * @param sessions - Array of session objects + * @returns Array of grouped sessions + */ +export const groupSessionsByDate = ( + sessions: Array>, +): SessionGroup[] => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const groups: { + [key: string]: Array>; + } = { + Today: [], + Yesterday: [], + 'This Week': [], + Older: [], + }; + + sessions.forEach((session) => { + const timestamp = + (session.lastUpdated as string) || (session.startTime as string) || ''; + if (!timestamp) { + groups['Older'].push(session); + return; + } + + const sessionDate = new Date(timestamp); + const sessionDay = new Date( + sessionDate.getFullYear(), + sessionDate.getMonth(), + sessionDate.getDate(), + ); + + if (sessionDay.getTime() === today.getTime()) { + groups['Today'].push(session); + } else if (sessionDay.getTime() === yesterday.getTime()) { + groups['Yesterday'].push(session); + } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { + groups['This Week'].push(session); + } else { + groups['Older'].push(session); + } + }); + + return Object.entries(groups) + .filter(([, sessions]) => sessions.length > 0) + .map(([label, sessions]) => ({ label, sessions })); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts b/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts new file mode 100644 index 00000000..0231f383 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts @@ -0,0 +1,92 @@ +/** + * @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/tempFileManager.ts b/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts new file mode 100644 index 00000000..8ab17e30 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Temporary file manager for creating and opening temporary files in webview + */ + +/** + * Creates a temporary file with the given content and opens it in VS Code + * @param content The content to write to the temporary file + * @param fileName Optional file name (without extension) + * @param fileExtension Optional file extension (defaults to .txt) + */ +export async function createAndOpenTempFile( + postMessage: (message: { + type: string; + data: Record; + }) => void, + content: string, + fileName: string = 'temp', + fileExtension: string = '.txt', +): Promise { + // Send message to VS Code extension to create and open temp file + postMessage({ + type: 'createAndOpenTempFile', + data: { + content, + fileName, + fileExtension, + }, + }); +} diff --git a/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts b/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts new file mode 100644 index 00000000..b1610597 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts @@ -0,0 +1,40 @@ +/** + * @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(); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts b/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts new file mode 100644 index 00000000..ed1b3135 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extract filename from full path + * @param fsPath Full path of the file + * @returns Filename (without path) + */ +export function getFileName(fsPath: string): string { + // Use path.basename logic: find the part after the last path separator + const lastSlash = Math.max(fsPath.lastIndexOf('/'), fsPath.lastIndexOf('\\')); + return lastSlash >= 0 ? fsPath.substring(lastSlash + 1) : fsPath; +} + +/** + * HTML escape function to prevent XSS attacks + * Convert special characters to HTML entities + * @param text Text to escape + * @returns Escaped text + */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js new file mode 100644 index 00000000..956f785c --- /dev/null +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/webview/**/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: { + keyframes: { + // CompletionMenu mount animation: fade in + slight upward slide + 'completion-menu-enter': { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + // Pulse animation for in-progress tool calls + 'pulse-slow': { + '0%, 100%': { opacity: '1' }, + '50%': { opacity: '0.5' }, + }, + // PermissionDrawer enter animation: slide up from bottom + 'slide-up': { + '0%': { transform: 'translateY(100%)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + animation: { + 'completion-menu-enter': 'completion-menu-enter 150ms ease-out both', + 'pulse-slow': 'pulse-slow 1.5s ease-in-out infinite', + 'slide-up': 'slide-up 200ms ease-out both', + }, + colors: { + qwen: { + orange: '#615fff', + 'clay-orange': '#4f46e5', + ivory: '#f5f5ff', + slate: '#141420', + green: '#6bcf7f', + // Status colors used by toolcall components + success: '#74c991', + error: '#c74e39', + warning: '#e1c08d', + loading: 'var(--app-secondary-foreground)', + }, + }, + borderRadius: { + small: '4px', + medium: '6px', + large: '8px', + }, + spacing: { + small: '4px', + medium: '8px', + large: '12px', + xlarge: '16px', + }, + }, + }, + plugins: [], +}; diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 02a9b53f..538ec461 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -4,6 +4,8 @@ "moduleResolution": "NodeNext", "target": "ES2022", "lib": ["ES2022", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "react", "sourceMap": true, "strict": true /* enable all strict type-checking options */ /* Additional Checks */ diff --git a/packages/vscode-ide-companion/vitest.config.ts b/packages/vscode-ide-companion/vitest.config.ts index 60f018c5..50c8ea3c 100644 --- a/packages/vscode-ide-companion/vitest.config.ts +++ b/packages/vscode-ide-companion/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'clover'],