diff --git a/build/package-lock.json b/build/package-lock.json index 1eff16c6b119c..92f3b6a4a3e70 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -4490,9 +4490,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 7658f382c9328..70e0339f77fa0 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -1068,9 +1068,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/extensions/json-language-features/server/src/utils/runner.ts b/extensions/json-language-features/server/src/utils/runner.ts index f7762f6ff31a0..5d4e541c6bd45 100644 --- a/extensions/json-language-features/server/src/utils/runner.ts +++ b/extensions/json-language-features/server/src/utils/runner.ts @@ -65,6 +65,5 @@ export function runSafe(runtime: RuntimeEnvironment, func: () => T, errorV } function cancelValue() { - console.log('cancelled'); return new ResponseError(LSPErrorCodes.RequestCancelled, 'Request cancelled'); } diff --git a/extensions/mermaid-chat-features/package-lock.json b/extensions/mermaid-chat-features/package-lock.json index 23436bd9f005c..26443f16e1a21 100644 --- a/extensions/mermaid-chat-features/package-lock.json +++ b/extensions/mermaid-chat-features/package-lock.json @@ -33,15 +33,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@antfu/utils": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-9.2.0.tgz", - "integrity": "sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/@braintree/sanitize-url": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", @@ -49,42 +40,42 @@ "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.1.tgz", - "integrity": "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.1.1", - "@chevrotain/types": "11.1.1", + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/gast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.1.tgz", - "integrity": "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.1.1", + "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.1.tgz", - "integrity": "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.1.tgz", - "integrity": "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.1.tgz", - "integrity": "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, "node_modules/@iconify/types": { @@ -94,37 +85,20 @@ "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.0.1.tgz", - "integrity": "sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", - "@antfu/utils": "^9.2.0", "@iconify/types": "^2.0.0", - "debug": "^4.4.1", - "globals": "^15.15.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.1.1", - "mlly": "^1.7.4" - } - }, - "node_modules/@iconify/utils/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "mlly": "^1.8.0" } }, "node_modules/@mermaid-js/parser": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", - "integrity": "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", "license": "MIT", "dependencies": { "langium": "^4.0.0" @@ -406,6 +380,16 @@ "license": "MIT", "optional": true }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vscode/codicons": { "version": "0.0.36", "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.36.tgz", @@ -414,9 +398,9 @@ "license": "CC-BY-4.0" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -426,16 +410,16 @@ } }, "node_modules/chevrotain": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", - "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.1.1", - "@chevrotain/gast": "11.1.1", - "@chevrotain/regexp-to-ast": "11.1.1", - "@chevrotain/types": "11.1.1", - "@chevrotain/utils": "11.1.1", + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", "lodash-es": "4.17.23" } }, @@ -452,18 +436,18 @@ } }, "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">= 10" } }, "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT" }, "node_modules/cose-base": { @@ -693,15 +677,6 @@ "node": ">=12" } }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -974,9 +949,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", - "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -984,32 +959,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.18", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", - "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -1027,12 +985,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", - "license": "MIT" - }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -1061,9 +1013,9 @@ } }, "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -1076,17 +1028,20 @@ "katex": "cli.js" } }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, - "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "license": "MIT" - }, "node_modules/langium": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", @@ -1110,27 +1065,10 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, - "node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", - "license": "MIT", - "dependencies": { - "mlly": "^1.7.4", - "pkg-types": "^2.3.0", - "quansync": "^0.2.11" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/marked": { @@ -1146,27 +1084,28 @@ } }, "node_modules/mermaid": { - "version": "11.12.3", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", - "integrity": "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^1.0.0", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.13", - "dayjs": "^1.11.18", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", - "marked": "^16.2.1", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -1174,44 +1113,21 @@ } }, "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "license": "MIT", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "ufo": "^1.6.3" } }, - "node_modules/mlly/node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "license": "MIT" - }, - "node_modules/mlly/node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/package-manager-detector": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", - "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, "node_modules/path-data-parser": { @@ -1227,14 +1143,14 @@ "license": "MIT" }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, "node_modules/points-on-curve": { @@ -1253,26 +1169,10 @@ "points-on-curve": "0.2.0" } }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", "license": "Unlicense" }, "node_modules/roughjs": { @@ -1306,10 +1206,13 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", - "license": "MIT" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/ts-dedent": { "version": "2.2.0", @@ -1321,9 +1224,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/undici-types": { diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 68f5271fef668..551dc478460d1 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -132,5 +132,8 @@ "dependencies": { "dompurify": "^3.3.2", "mermaid": "^11.12.3" + }, + "overrides": { + "lodash-es": "4.18.1" } } diff --git a/package-lock.json b/package-lock.json index 94ca551a845c5..70d1ae1551faf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,16 +32,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -52,7 +52,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.12", "open": "^10.1.2", - "playwright-core": "1.59.0-alpha-2026-02-20", + "playwright-core": "1.59.1", "ssh2": "^1.16.0", "tas-client": "0.3.1", "undici": "^7.24.0", @@ -4285,30 +4285,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.191.tgz", - "integrity": "sha512-u+0smTVylu9IAntE2OPfD1ZqZ5TNIcSWM1fA/tu1tryGIscax9cn6V2U7T4kkTEFf3A4a3PKgCrKufBclXebjQ==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.196.tgz", + "integrity": "sha512-OEYGoh++tQ1zk1RbfR0suJY+qv7tlHGzopS00G7tLgma9VaWm2a5rcu04uCtEQ0NPuPygEmOruDiTmf+PbxhdQ==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.191.tgz", - "integrity": "sha512-gX2WEQV2N7A9fx5VpzXK9GIqazUJKBreqEOuUcQPnY2ECJqVsC0uaoK9A/rhm2JuSkHRcqBt/pRVlT4Ki5KsZg==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.196.tgz", + "integrity": "sha512-ioiof4g9yfbZXu7ReoKodJjnfh3ZpACaYZLdrxgcsOZ6y0mZ/1EMDdTpZxLrGCqI+8uOj+9qa6oI2uTsC3LBQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.191.tgz", - "integrity": "sha512-LFAPEUna5o7nZSJrlV8rrhU6WIC30btdqPf4PNAo5pxWjCWPBqlD1XkTmeYWGpsxk2OaXec/MGjG03k6Rg2z2w==", + "version": "0.11.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.196.tgz", + "integrity": "sha512-Mf3V6WxNxuP8hcw3Jk+FMRYi6z2xwCfDnjoGn+bpLRV5Qnu6X7uZGOWCSemRgRJeaM0ufxHt6a4Lnvmpb4yjGQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -4318,7 +4318,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -4340,63 +4340,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.191.tgz", - "integrity": "sha512-aImBOklbz37LqF4JsXe6cSTyFIpV5hxU4G/DS1IaFqbYPPMwWGdOUIB3L+TWwcMJh9hv1iwx5PbxNNi0KaylVw==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.196.tgz", + "integrity": "sha512-WDg5A0ZU0vUJjUnDwhFjdMi9ErTMdSZaZhB/+6FgMKyAzlvTkcLjo0HHNFPOr8U3r+BSMqLm6pgwxcwvlLBe1Q==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.191.tgz", - "integrity": "sha512-fv+TQkhDXYxAeJ31Mi4C4BPFLcffFOHl/vDceMlzaqHB+XHTBo6BDRcXAWBAgA0rvChZrYYTQ/JCPq9R506JSw==", + "version": "0.17.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.196.tgz", + "integrity": "sha512-dsokQjZdPIOG76LcqARs7jzSmKP/VdBKE9HgZuidHbhOUn4T2Yb2IjfdZkEkWcLA+p/c+R/YPNh2TyC4PQv9vg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.191.tgz", - "integrity": "sha512-Kzj/J52MEH8B1p84CZ0XEY3/engNDO1sJKmpZDLr0WP+EKvh5uwZe0bD5X5tNLNM5ZEFpMWailwTrGF9tSAd2g==", + "version": "0.15.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.196.tgz", + "integrity": "sha512-/FWg+jh+OrMBpNFLCLMmuprF1k5le9LN+8XNm04hZuImrjgvdA+sf76zmIcHb6PlkPsyDHY1cm+R93ObtJkRqA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.191.tgz", - "integrity": "sha512-6FR4CjI0te0bLWF7jr7ujpjVOW1kGFl+rKWbblFRNhENuN6gT1phIm4V1L4N9Rr6seTku272YRnO0nAHWZNe6g==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.196.tgz", + "integrity": "sha512-OVwpNleRVX/UdZT9/yDvweMM8BhqYc9HVtBY1ob8Ig9UikTXBdiB0eouOY5AKrCdSXS4G+clI7Yvso6VaRgMAg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.190", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.190.tgz", - "integrity": "sha512-9ABzdVhovpseQuD7v5sDz/4UZXvX7y8CH+EaF+LiP/HK6JwdWzVo9OftyWGNdHrOqo43PO7RTeC3eUQq9qqQGw==", + "version": "0.20.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.195.tgz", + "integrity": "sha512-+Oftc7iKfdQ3nBAqhrdGlDR6msMxiurY+/GSvSItAiTd+euOGdbxfznE8TP7Q5Vm044ev5JajULDH3gJbhPJEA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.191.tgz", - "integrity": "sha512-2dXTApeat9zr/clkEydw/uoBi3WEoXDGZZIW1aLthpj2pOqHfxlOdWIpPHeVhR07TAYble2OZJ0ydjfXWntNgA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.196.tgz", + "integrity": "sha512-xDrTvf+W2mqrKRZvexFLf5imgfbAbWixwH4kR9AIMEzi+Ud+8djY1GBRFxumORI/ckoogmdkgFH2RVl6Dm1deA==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.191.tgz", - "integrity": "sha512-c50KmCnftJRZa3cXVBxcixTyeq4Brs603DHxYGZ0z0a58LkhKdfzOfKwYKxHfiZlsOlX58Xk21BJ4d1UrCuCAA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.196.tgz", + "integrity": "sha512-+EjrGyk5WoJL89YL/EKrNJZNEJeikkUf7vwgVGVX4/gX0WYUn8Aci+j91DM4XwYoyGmcfMvKM/u1GdX7tDPmtA==", "license": "MIT", "workspaces": [ "addons/*" @@ -4496,19 +4496,6 @@ "agent-browser": "bin/agent-browser.js" } }, - "node_modules/agent-browser/node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -13109,16 +13096,16 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -15515,9 +15502,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.0-alpha-2026-02-20", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", - "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index 98c390f5b5468..2e3f41fb4baff 100644 --- a/package.json +++ b/package.json @@ -104,16 +104,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -124,7 +124,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.12", "open": "^10.1.2", - "playwright-core": "1.59.0-alpha-2026-02-20", + "playwright-core": "1.59.1", "ssh2": "^1.16.0", "tas-client": "0.3.1", "undici": "^7.24.0", diff --git a/remote/package-lock.json b/remote/package-lock.json index 39910b1088054..097931ffcf8bd 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -25,16 +25,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -752,30 +752,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.191.tgz", - "integrity": "sha512-u+0smTVylu9IAntE2OPfD1ZqZ5TNIcSWM1fA/tu1tryGIscax9cn6V2U7T4kkTEFf3A4a3PKgCrKufBclXebjQ==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.196.tgz", + "integrity": "sha512-OEYGoh++tQ1zk1RbfR0suJY+qv7tlHGzopS00G7tLgma9VaWm2a5rcu04uCtEQ0NPuPygEmOruDiTmf+PbxhdQ==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.191.tgz", - "integrity": "sha512-gX2WEQV2N7A9fx5VpzXK9GIqazUJKBreqEOuUcQPnY2ECJqVsC0uaoK9A/rhm2JuSkHRcqBt/pRVlT4Ki5KsZg==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.196.tgz", + "integrity": "sha512-ioiof4g9yfbZXu7ReoKodJjnfh3ZpACaYZLdrxgcsOZ6y0mZ/1EMDdTpZxLrGCqI+8uOj+9qa6oI2uTsC3LBQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.191.tgz", - "integrity": "sha512-LFAPEUna5o7nZSJrlV8rrhU6WIC30btdqPf4PNAo5pxWjCWPBqlD1XkTmeYWGpsxk2OaXec/MGjG03k6Rg2z2w==", + "version": "0.11.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.196.tgz", + "integrity": "sha512-Mf3V6WxNxuP8hcw3Jk+FMRYi6z2xwCfDnjoGn+bpLRV5Qnu6X7uZGOWCSemRgRJeaM0ufxHt6a4Lnvmpb4yjGQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -785,67 +785,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.191.tgz", - "integrity": "sha512-aImBOklbz37LqF4JsXe6cSTyFIpV5hxU4G/DS1IaFqbYPPMwWGdOUIB3L+TWwcMJh9hv1iwx5PbxNNi0KaylVw==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.196.tgz", + "integrity": "sha512-WDg5A0ZU0vUJjUnDwhFjdMi9ErTMdSZaZhB/+6FgMKyAzlvTkcLjo0HHNFPOr8U3r+BSMqLm6pgwxcwvlLBe1Q==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.191.tgz", - "integrity": "sha512-fv+TQkhDXYxAeJ31Mi4C4BPFLcffFOHl/vDceMlzaqHB+XHTBo6BDRcXAWBAgA0rvChZrYYTQ/JCPq9R506JSw==", + "version": "0.17.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.196.tgz", + "integrity": "sha512-dsokQjZdPIOG76LcqARs7jzSmKP/VdBKE9HgZuidHbhOUn4T2Yb2IjfdZkEkWcLA+p/c+R/YPNh2TyC4PQv9vg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.191.tgz", - "integrity": "sha512-Kzj/J52MEH8B1p84CZ0XEY3/engNDO1sJKmpZDLr0WP+EKvh5uwZe0bD5X5tNLNM5ZEFpMWailwTrGF9tSAd2g==", + "version": "0.15.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.196.tgz", + "integrity": "sha512-/FWg+jh+OrMBpNFLCLMmuprF1k5le9LN+8XNm04hZuImrjgvdA+sf76zmIcHb6PlkPsyDHY1cm+R93ObtJkRqA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.191.tgz", - "integrity": "sha512-6FR4CjI0te0bLWF7jr7ujpjVOW1kGFl+rKWbblFRNhENuN6gT1phIm4V1L4N9Rr6seTku272YRnO0nAHWZNe6g==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.196.tgz", + "integrity": "sha512-OVwpNleRVX/UdZT9/yDvweMM8BhqYc9HVtBY1ob8Ig9UikTXBdiB0eouOY5AKrCdSXS4G+clI7Yvso6VaRgMAg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.190", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.190.tgz", - "integrity": "sha512-9ABzdVhovpseQuD7v5sDz/4UZXvX7y8CH+EaF+LiP/HK6JwdWzVo9OftyWGNdHrOqo43PO7RTeC3eUQq9qqQGw==", + "version": "0.20.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.195.tgz", + "integrity": "sha512-+Oftc7iKfdQ3nBAqhrdGlDR6msMxiurY+/GSvSItAiTd+euOGdbxfznE8TP7Q5Vm044ev5JajULDH3gJbhPJEA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.191.tgz", - "integrity": "sha512-2dXTApeat9zr/clkEydw/uoBi3WEoXDGZZIW1aLthpj2pOqHfxlOdWIpPHeVhR07TAYble2OZJ0ydjfXWntNgA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.196.tgz", + "integrity": "sha512-xDrTvf+W2mqrKRZvexFLf5imgfbAbWixwH4kR9AIMEzi+Ud+8djY1GBRFxumORI/ckoogmdkgFH2RVl6Dm1deA==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.191.tgz", - "integrity": "sha512-c50KmCnftJRZa3cXVBxcixTyeq4Brs603DHxYGZ0z0a58LkhKdfzOfKwYKxHfiZlsOlX58Xk21BJ4d1UrCuCAA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.196.tgz", + "integrity": "sha512-+EjrGyk5WoJL89YL/EKrNJZNEJeikkUf7vwgVGVX4/gX0WYUn8Aci+j91DM4XwYoyGmcfMvKM/u1GdX7tDPmtA==", "license": "MIT", "workspaces": [ "addons/*" @@ -1220,9 +1220,9 @@ } }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lru-cache": { diff --git a/remote/package.json b/remote/package.json index 84447dd4c4cd8..9a39d202641e3 100644 --- a/remote/package.json +++ b/remote/package.json @@ -20,16 +20,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 6c311d20a520f..ee9f4879f98f9 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/xterm": "^6.1.0-beta.196", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -100,30 +100,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.191.tgz", - "integrity": "sha512-u+0smTVylu9IAntE2OPfD1ZqZ5TNIcSWM1fA/tu1tryGIscax9cn6V2U7T4kkTEFf3A4a3PKgCrKufBclXebjQ==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.196.tgz", + "integrity": "sha512-OEYGoh++tQ1zk1RbfR0suJY+qv7tlHGzopS00G7tLgma9VaWm2a5rcu04uCtEQ0NPuPygEmOruDiTmf+PbxhdQ==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.191.tgz", - "integrity": "sha512-gX2WEQV2N7A9fx5VpzXK9GIqazUJKBreqEOuUcQPnY2ECJqVsC0uaoK9A/rhm2JuSkHRcqBt/pRVlT4Ki5KsZg==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.196.tgz", + "integrity": "sha512-ioiof4g9yfbZXu7ReoKodJjnfh3ZpACaYZLdrxgcsOZ6y0mZ/1EMDdTpZxLrGCqI+8uOj+9qa6oI2uTsC3LBQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.191.tgz", - "integrity": "sha512-LFAPEUna5o7nZSJrlV8rrhU6WIC30btdqPf4PNAo5pxWjCWPBqlD1XkTmeYWGpsxk2OaXec/MGjG03k6Rg2z2w==", + "version": "0.11.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.196.tgz", + "integrity": "sha512-Mf3V6WxNxuP8hcw3Jk+FMRYi6z2xwCfDnjoGn+bpLRV5Qnu6X7uZGOWCSemRgRJeaM0ufxHt6a4Lnvmpb4yjGQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -133,58 +133,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.191.tgz", - "integrity": "sha512-aImBOklbz37LqF4JsXe6cSTyFIpV5hxU4G/DS1IaFqbYPPMwWGdOUIB3L+TWwcMJh9hv1iwx5PbxNNi0KaylVw==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.196.tgz", + "integrity": "sha512-WDg5A0ZU0vUJjUnDwhFjdMi9ErTMdSZaZhB/+6FgMKyAzlvTkcLjo0HHNFPOr8U3r+BSMqLm6pgwxcwvlLBe1Q==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.191.tgz", - "integrity": "sha512-fv+TQkhDXYxAeJ31Mi4C4BPFLcffFOHl/vDceMlzaqHB+XHTBo6BDRcXAWBAgA0rvChZrYYTQ/JCPq9R506JSw==", + "version": "0.17.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.196.tgz", + "integrity": "sha512-dsokQjZdPIOG76LcqARs7jzSmKP/VdBKE9HgZuidHbhOUn4T2Yb2IjfdZkEkWcLA+p/c+R/YPNh2TyC4PQv9vg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.191.tgz", - "integrity": "sha512-Kzj/J52MEH8B1p84CZ0XEY3/engNDO1sJKmpZDLr0WP+EKvh5uwZe0bD5X5tNLNM5ZEFpMWailwTrGF9tSAd2g==", + "version": "0.15.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.196.tgz", + "integrity": "sha512-/FWg+jh+OrMBpNFLCLMmuprF1k5le9LN+8XNm04hZuImrjgvdA+sf76zmIcHb6PlkPsyDHY1cm+R93ObtJkRqA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.191.tgz", - "integrity": "sha512-6FR4CjI0te0bLWF7jr7ujpjVOW1kGFl+rKWbblFRNhENuN6gT1phIm4V1L4N9Rr6seTku272YRnO0nAHWZNe6g==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.196.tgz", + "integrity": "sha512-OVwpNleRVX/UdZT9/yDvweMM8BhqYc9HVtBY1ob8Ig9UikTXBdiB0eouOY5AKrCdSXS4G+clI7Yvso6VaRgMAg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.190", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.190.tgz", - "integrity": "sha512-9ABzdVhovpseQuD7v5sDz/4UZXvX7y8CH+EaF+LiP/HK6JwdWzVo9OftyWGNdHrOqo43PO7RTeC3eUQq9qqQGw==", + "version": "0.20.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.195.tgz", + "integrity": "sha512-+Oftc7iKfdQ3nBAqhrdGlDR6msMxiurY+/GSvSItAiTd+euOGdbxfznE8TP7Q5Vm044ev5JajULDH3gJbhPJEA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.191.tgz", - "integrity": "sha512-c50KmCnftJRZa3cXVBxcixTyeq4Brs603DHxYGZ0z0a58LkhKdfzOfKwYKxHfiZlsOlX58Xk21BJ4d1UrCuCAA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.196.tgz", + "integrity": "sha512-+EjrGyk5WoJL89YL/EKrNJZNEJeikkUf7vwgVGVX4/gX0WYUn8Aci+j91DM4XwYoyGmcfMvKM/u1GdX7tDPmtA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index e0487531ed39c..53d00c81eeba8 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/xterm": "^6.1.0-beta.196", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/src/vs/base/browser/domStylesheets.ts b/src/vs/base/browser/domStylesheets.ts index c338502d541da..72fa42df1a6a5 100644 --- a/src/vs/base/browser/domStylesheets.ts +++ b/src/vs/base/browser/domStylesheets.ts @@ -89,7 +89,7 @@ function cloneGlobalStyleSheet(globalStylesheet: HTMLStyleElement, globalStylesh targetWindow.document.head.appendChild(clone); disposables.add(toDisposable(() => clone.remove())); - for (const rule of getDynamicStyleSheetRules(globalStylesheet)) { + for (const rule of globalStylesheet.sheet?.cssRules ?? []) { clone.sheet?.insertRule(rule.cssText, clone.sheet?.cssRules.length); } @@ -111,16 +111,6 @@ function getSharedStyleSheet(): HTMLStyleElement { return _sharedStyleSheet; } -function getDynamicStyleSheetRules(style: HTMLStyleElement) { - if (style?.sheet?.rules) { - return style.sheet.rules; // Chrome, IE - } - if (style?.sheet?.cssRules) { - return style.sheet.cssRules; // FF - } - return []; -} - export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { if (!style || !cssText) { return; @@ -139,7 +129,7 @@ export function removeCSSRulesContainingSelector(ruleName: string, style = getSh return; } - const rules = getDynamicStyleSheetRules(style); + const rules = style.sheet?.cssRules ?? []; const toDelete: number[] = []; for (let i = 0; i < rules.length; i++) { const rule = rules[i]; diff --git a/src/vs/base/browser/ui/findinput/findInput.ts b/src/vs/base/browser/ui/findinput/findInput.ts index 80f9fea1f8792..aefd0b69b1ef4 100644 --- a/src/vs/base/browser/ui/findinput/findInput.ts +++ b/src/vs/base/browser/ui/findinput/findInput.ts @@ -174,9 +174,9 @@ export class FindInput extends Widget { })); // Arrow-Key support to navigate between options - const indexes = [this.caseSensitive.domNode, this.wholeWords.domNode, this.regex.domNode]; this.onkeydown(this.domNode, (event: IKeyboardEvent) => { if (event.equals(KeyCode.LeftArrow) || event.equals(KeyCode.RightArrow) || event.equals(KeyCode.Escape)) { + const indexes = this.getToggleDomNodes(); const index = indexes.indexOf(this.domNode.ownerDocument.activeElement); if (index >= 0) { let newIndex: number = -1; @@ -315,6 +315,23 @@ export class FindInput extends Widget { this.updateInputBoxPadding(); } + protected getToggleDomNodes(): HTMLElement[] { + const nodes: HTMLElement[] = []; + if (this.caseSensitive) { + nodes.push(this.caseSensitive.domNode); + } + if (this.wholeWords) { + nodes.push(this.wholeWords.domNode); + } + if (this.regex) { + nodes.push(this.regex.domNode); + } + for (const toggle of this.additionalToggles) { + nodes.push(toggle.domNode); + } + return nodes; + } + public setActions(actions: ReadonlyArray | undefined, actionViewItemProvider?: IActionViewItemProvider): void { this.inputBox.setActions(actions, actionViewItemProvider); } diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index 6e3b6e4a38d95..844d4c02c4177 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../base/common/buffer.js'; +import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../base/common/resources.js'; @@ -11,17 +11,20 @@ import { URI } from '../../../base/common/uri.js'; import { createFileSystemProviderError, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProvider, IFileWriteOptions, IStat } from '../../files/common/files.js'; import { fromAgentHostUri, toAgentHostUri } from './agentHostUri.js'; import { type IAgentConnection } from './agentService.js'; -import { IBrowseDirectoryResult, IDirectoryEntry, IFetchContentResult } from './state/protocol/commands.js'; +import { ContentEncoding, type IDirectoryEntry, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult } from './state/protocol/commands.js'; /** - * Minimal interface for browsing and fetching files from a remote endpoint. + * Interface for performing resource operations on a remote endpoint. * * Both {@link IAgentConnection} (client→server) and client-exposed * filesystems (server→client) satisfy this contract. */ export interface IRemoteFilesystemConnection { - browseDirectory(uri: URI): Promise; - fetchContent(uri: URI): Promise; + resourceList(uri: URI): Promise; + resourceRead(uri: URI): Promise; + resourceWrite(params: IResourceWriteParams): Promise; + resourceDelete(params: IResourceDeleteParams): Promise; + resourceMove(params: IResourceMoveParams): Promise; } /** @@ -39,23 +42,10 @@ export function agentHostRemotePath(uri: URI): string { return fromAgentHostUri(uri).path; } -// ---- Remote filesystem connection ------------------------------------------- - -/** - * Minimal interface for browsing and fetching files from a remote endpoint. - * - * Both {@link IAgentConnection} (client→server) and client-exposed - * filesystems (server→client) satisfy this contract. - */ -export interface IRemoteFilesystemConnection { - browseDirectory(uri: URI): Promise; - fetchContent(uri: URI): Promise; -} - // ---- Abstract base ---------------------------------------------------------- /** - * Read-only {@link IFileSystemProvider} that proxies filesystem operations + * {@link IFileSystemProvider} that proxies filesystem operations * through a {@link IRemoteFilesystemConnection}. * * URIs encode the original scheme and authority in the path so any remote @@ -67,9 +57,8 @@ export interface IRemoteFilesystemConnection { export abstract class AHPFileSystemProvider extends Disposable implements IFileSystemProvider { readonly capabilities = - FileSystemProviderCapabilities.Readonly | FileSystemProviderCapabilities.PathCaseSensitive | - FileSystemProviderCapabilities.FileReadWrite; // required for the file service to resolve directory contents + FileSystemProviderCapabilities.FileReadWrite; private readonly _onDidChangeCapabilities = this._register(new Emitter()); readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; @@ -137,7 +126,10 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS const connection = this._getConnection(resource.authority); try { const originalUri = this._decodeUri(resource); - const result = await connection.fetchContent(originalUri); + const result = await connection.resourceRead(originalUri); + if (result.encoding === ContentEncoding.Base64) { + return decodeBase64(result.data).buffer; + } return VSBuffer.fromString(result.data).buffer; } catch (err) { throw createFileSystemProviderError( @@ -147,20 +139,52 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS } } - async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise { - throw createFileSystemProviderError('writeFile not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); + async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = this._decodeUri(resource); + await connection.resourceWrite({ + uri: originalUri.toString(), + data: VSBuffer.wrap(content).toString(), + encoding: ContentEncoding.Utf8, + }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } } async mkdir(): Promise { throw createFileSystemProviderError('mkdir not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); } - async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { - throw createFileSystemProviderError('delete not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); + async delete(resource: URI, opts: IFileDeleteOptions): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = this._decodeUri(resource); + await connection.resourceDelete({ uri: originalUri.toString(), recursive: opts.recursive }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } } - async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { - throw createFileSystemProviderError('rename not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); + async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise { + const connection = this._getConnection(from.authority); + try { + const originalFrom = this._decodeUri(from); + const originalTo = this._decodeUri(to); + await connection.resourceMove({ source: originalFrom.toString(), destination: originalTo.toString(), failIfExists: !opts.overwrite }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } } // ---- Internals ---------------------------------------------------------- @@ -177,7 +201,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS const connection = this._getConnection(authority); try { const originalUri = this._decodeUri(resource); - const result = await connection.browseDirectory(originalUri); + const result = await connection.resourceList(originalUri); return result.entries; } catch (err) { throw createFileSystemProviderError( @@ -191,7 +215,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS // ---- Agent Host filesystem (client reads agent host files) ------------------ /** - * Read-only filesystem provider for accessing agent host files from the + * Filesystem provider for accessing agent host files from the * client side. Registered under the `vscode-agent-host` scheme. * * ``` diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 4fcbc7fde6b1f..9610ec1f9c717 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; -import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot, IWriteFileParams, IWriteFileResult } from './state/sessionProtocol.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. @@ -491,19 +491,34 @@ export interface IAgentService { * List the contents of a directory on the agent host's filesystem. * Used by the client to drive a remote folder picker before session creation. */ - browseDirectory(uri: URI): Promise; + resourceList(uri: URI): Promise; /** - * Fetch stored content by URI from the agent host (e.g. file edit snapshots, + * Read stored content by URI from the agent host (e.g. file edit snapshots, * or reading files from the remote filesystem). */ - fetchContent(uri: URI): Promise; + resourceRead(uri: URI): Promise; /** * Write content to a file on the agent host's filesystem. * Used for undo/redo operations on file edits. */ - writeFile(params: IWriteFileParams): Promise; + resourceWrite(params: IResourceWriteParams): Promise; + + /** + * Copy a resource from one URI to another on the agent host's filesystem. + */ + resourceCopy(params: IResourceCopyParams): Promise; + + /** + * Delete a resource at a URI on the agent host's filesystem. + */ + resourceDelete(params: IResourceDeleteParams): Promise; + + /** + * Move (rename) a resource from one URI to another on the agent host's filesystem. + */ + resourceMove(params: IResourceMoveParams): Promise; } /** diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 4a1b2463ee36c..cc9469b5f00f3 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -6,6 +6,7 @@ import { IDisposable, IReference } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { FileEditKind } from './state/sessionState.js'; export const ISessionDataService = createDecorator('sessionDataService'); @@ -20,8 +21,12 @@ export interface IFileEditRecord { turnId: string; /** The tool call that produced this edit. */ toolCallId: string; - /** Absolute file path that was edited. */ + /** Primary file path (after-path for edits/creates/renames, before-path for deletes). */ filePath: string; + /** The kind of file operation. */ + kind: FileEditKind; + /** For renames, the original file path before the move. */ + originalPath?: string; /** Number of lines added (informational, for diff metadata). */ addedLines: number | undefined; /** Number of lines removed (informational, for diff metadata). */ @@ -31,12 +36,15 @@ export interface IFileEditRecord { /** * The before/after content blobs for a single file edit. * Retrieved on demand via {@link ISessionDatabase.readFileEditContent}. + * + * For creates, `beforeContent` is absent. + * For deletes, `afterContent` is absent. */ export interface IFileEditContent { - /** File content before the edit (may be empty for newly created files). */ - beforeContent: Uint8Array; - /** File content after the edit. */ - afterContent: Uint8Array; + /** File content before the edit. Absent for file creations. */ + beforeContent?: Uint8Array; + /** File content after the edit. Absent for file deletions. */ + afterContent?: Uint8Array; } // ---- Session database --------------------------------------------------- diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index ddbe3aef6b34b..b2f37b431b132 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -2743bf6 +b13578c diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 889d4a883b87a..fd67388ee2124 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -237,7 +237,7 @@ export interface IListSessionsResult { items: ISessionSummary[]; } -// ─── fetchContent ──────────────────────────────────────────────────────────── +// ─── resourceRead ──────────────────────────────────────────────────────── /** * Encoding of fetched content data. @@ -250,7 +250,7 @@ export const enum ContentEncoding { } /** - * Fetches large content referenced by a `ContentRef` in the state tree. + * Reads the content of a resource by URI. * * Content references keep the state tree small by storing large data (images, * long tool outputs) by reference rather than inline. @@ -259,7 +259,7 @@ export const enum ContentEncoding { * use `utf-8` encoding. * * @category Commands - * @method fetchContent + * @method resourceRead * @direction Client → Server * @messageType Request * @version 1 @@ -268,7 +268,7 @@ export const enum ContentEncoding { * @example * ```jsonc * // Client → Server - * { "jsonrpc": "2.0", "id": 10, "method": "fetchContent", + * { "jsonrpc": "2.0", "id": 10, "method": "resourceRead", * "params": { "uri": "copilot://content/img-1" } } * * // Server → Client @@ -279,7 +279,7 @@ export const enum ContentEncoding { * }} * ``` */ -export interface IFetchContentParams { +export interface IResourceReadParams { /** Content URI from a `ContentRef` */ uri: string; /** Preferred encoding for the returned data (default: server-chosen) */ @@ -287,13 +287,13 @@ export interface IFetchContentParams { } /** - * Result of the `fetchContent` command. + * Result of the `resourceRead` command. * * The server SHOULD honor the `encoding` requested in the params. If the * server cannot provide the requested encoding, it MUST fall back to either * `base64` or `utf-8`. */ -export interface IFetchContentResult { +export interface IResourceReadResult { /** Content encoded as a string */ data: string; /** How `data` is encoded */ @@ -302,7 +302,7 @@ export interface IFetchContentResult { contentType?: string; } -// ─── writeFile ─────────────────────────────────────────────────────────────── +// ─── resourceWrite ─────────────────────────────────────────────────────────── /** * Writes content to a file on the server's filesystem. @@ -314,7 +314,7 @@ export interface IFetchContentResult { * overwritten unless `createOnly` is set. * * @category Commands - * @method writeFile + * @method resourceWrite * @direction Client → Server * @messageType Request * @version 1 @@ -324,7 +324,7 @@ export interface IFetchContentResult { * @example * ```jsonc * // Client → Server - * { "jsonrpc": "2.0", "id": 11, "method": "writeFile", + * { "jsonrpc": "2.0", "id": 11, "method": "resourceWrite", * "params": { "uri": "file:///workspace/hello.txt", "data": "SGVsbG8=", * "encoding": "base64", "contentType": "text/plain" } } * @@ -332,7 +332,7 @@ export interface IFetchContentResult { * { "jsonrpc": "2.0", "id": 11, "result": {} } * ``` */ -export interface IWriteFileParams { +export interface IResourceWriteParams { /** Target file URI on the server filesystem */ uri: URI; /** Content encoded as a string */ @@ -349,14 +349,14 @@ export interface IWriteFileParams { } /** - * Result of the `writeFile` command. + * Result of the `resourceWrite` command. * * An empty object on success. */ -export interface IWriteFileResult { +export interface IResourceWriteResult { } -// ─── browseDirectory ──────────────────────────────────────────────────────── +// ─── resourceList ──────────────────────────────────────────────────────── /** * Lists directory entries at a file URI on the server's filesystem. @@ -369,20 +369,20 @@ export interface IWriteFileResult { * server MUST return a JSON-RPC error. * * @category Commands - * @method browseDirectory + * @method resourceList * @direction Client → Server * @messageType Request * @version 1 * @throws `NotFound` (`-32008`) if the directory does not exist. * @throws `PermissionDenied` (`-32009`) if the client is not permitted to browse the directory. */ -export interface IBrowseDirectoryParams { +export interface IResourceListParams { /** Directory URI on the server filesystem */ uri: URI; } /** - * Directory entry returned by `browseDirectory`. + * Directory entry returned by `resourceList`. */ export interface IDirectoryEntry { /** Base name of the entry */ @@ -392,9 +392,9 @@ export interface IDirectoryEntry { } /** - * Result of the `browseDirectory` command. + * Result of the `resourceList` command. */ -export interface IBrowseDirectoryResult { +export interface IResourceListResult { /** Entries directly contained in the requested directory */ entries: IDirectoryEntry[]; } @@ -483,31 +483,109 @@ export interface IDispatchActionParams { action: IStateAction; } -// ─── browseDirectory ──────────────────────────────────────────────────── +// ─── resourceCopy ──────────────────────────────────────────────────────────── /** - * Lists the contents of a directory on the server. Used by clients to - * present directory pickers or file browsers. + * Copies a resource from one URI to another on the server's filesystem. + * + * If the destination already exists, it is overwritten unless `failIfExists` + * is set. * * @category Commands - * @method browseDirectory + * @method resourceCopy * @direction Client → Server * @messageType Request * @version 1 + * @throws `NotFound` (`-32008`) if the source does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to read the source or write to the destination. + * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. */ -export interface IBrowseDirectoryParams { - /** Directory path to browse. Omit to list the default/root directory. */ - directory?: string; +export interface IResourceCopyParams { + /** Source URI to copy from */ + source: URI; + /** Destination URI to copy to */ + destination: URI; + /** + * If `true`, the server MUST fail if the destination already exists instead + * of overwriting it. + */ + failIfExists?: boolean; } /** - * A single entry in a directory listing. + * Result of the `resourceCopy` command. + * + * An empty object on success. */ -export interface IBrowseDirectoryEntry { - /** Entry name (not a full path) */ - name: string; - /** Whether this entry is a directory */ - isDirectory: boolean; +export interface IResourceCopyResult { +} + +// ─── resourceDelete ────────────────────────────────────────────────────────── + +/** + * Deletes a resource at a URI on the server's filesystem. + * + * @category Commands + * @method resourceDelete + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the resource does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to delete the resource. + */ +export interface IResourceDeleteParams { + /** URI of the resource to delete */ + uri: URI; + /** + * If `true` and the target is a directory, delete it and all its contents + * recursively. If `false` (default), deleting a non-empty directory MUST fail. + */ + recursive?: boolean; +} + +/** + * Result of the `resourceDelete` command. + * + * An empty object on success. + */ +export interface IResourceDeleteResult { +} + +// ─── resourceMove ──────────────────────────────────────────────────────────── + +/** + * Moves (renames) a resource from one URI to another on the server's filesystem. + * + * If the destination already exists, it is overwritten unless `failIfExists` + * is set. + * + * @category Commands + * @method resourceMove + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the source does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to move the resource. + * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. + */ +export interface IResourceMoveParams { + /** Source URI to move from */ + source: URI; + /** Destination URI to move to */ + destination: URI; + /** + * If `true`, the server MUST fail if the destination already exists instead + * of overwriting it. + */ + failIfExists?: boolean; +} + +/** + * Result of the `resourceMove` command. + * + * An empty object on success. + */ +export interface IResourceMoveResult { } // ─── authenticate ──────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index d22126e922298..bcf0e7947de3b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -68,7 +68,7 @@ export const AhpErrorCodes = { PermissionDenied: -32009, /** * The target resource already exists and the operation does not allow - * overwriting (e.g. `writeFile` with `createOnly: true`). + * overwriting (e.g. `resourceWrite` with `createOnly: true`). */ AlreadyExists: -32010, } as const; diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 829945a73c597..253893ab6619a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IWriteFileParams, IWriteFileResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; import type { IActionEnvelope } from './actions.js'; import type { IProtocolNotification } from './notifications.js'; @@ -63,9 +63,12 @@ export interface ICommandMap { 'createSession': { params: ICreateSessionParams; result: null }; 'disposeSession': { params: IDisposeSessionParams; result: null }; 'listSessions': { params: IListSessionsParams; result: IListSessionsResult }; - 'fetchContent': { params: IFetchContentParams; result: IFetchContentResult }; - 'writeFile': { params: IWriteFileParams; result: IWriteFileResult }; - 'browseDirectory': { params: IBrowseDirectoryParams; result: IBrowseDirectoryResult }; + 'resourceRead': { params: IResourceReadParams; result: IResourceReadResult }; + 'resourceWrite': { params: IResourceWriteParams; result: IResourceWriteResult }; + 'resourceList': { params: IResourceListParams; result: IResourceListResult }; + 'resourceCopy': { params: IResourceCopyParams; result: IResourceCopyResult }; + 'resourceDelete': { params: IResourceDeleteParams; result: IResourceDeleteResult }; + 'resourceMove': { params: IResourceMoveParams; result: IResourceMoveResult }; 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 6f27c6a0d2480..49cefab87d7af 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -454,20 +454,26 @@ export interface IMarkdownResponsePart { /** * A reference to large content stored outside the state tree. - * - * @category Response Parts */ export interface IContentRef { - /** Discriminant */ - kind: ResponsePartKind.ContentRef; /** Content URI */ - uri: string; + uri: URI; /** Approximate size in bytes */ sizeHint?: number; /** Content MIME type */ contentType?: string; } +/** + * A content part that's a reference to large content stored outside the state tree. + * + * @category Response Parts + */ +export interface IResourceReponsePart extends IContentRef { + /** Discriminant */ + kind: ResponsePartKind.ContentRef; +} + /** * A tool call represented as a response part. * @@ -501,7 +507,7 @@ export interface IReasoningResponsePart { /** * @category Response Parts */ -export type IResponsePart = IMarkdownResponsePart | IContentRef | IToolCallResponsePart | IReasoningResponsePart; +export type IResponsePart = IMarkdownResponsePart | IResourceReponsePart | IToolCallResponsePart | IReasoningResponsePart; // ─── Tool Call Types ───────────────────────────────────────────────────────── @@ -786,7 +792,8 @@ export interface IToolAnnotations { */ export const enum ToolResultContentType { Text = 'text', - Binary = 'binary', + EmbeddedResource = 'embeddedResource', + Resource = 'resource', FileEdit = 'fileEdit', } @@ -804,33 +811,55 @@ export interface IToolResultTextContent { } /** - * Base64-encoded binary content in a tool result. + * Base64-encoded binary content embedded in a tool result. * - * Mirrors MCP `ImageContent` but generalized to any binary content type. + * Mirrors MCP `EmbeddedResource` for inline binary data. * * @category Tool Result Content */ -export interface IToolResultBinaryContent { - type: ToolResultContentType.Binary; +export interface IToolResultEmbeddedResourceContent { + type: ToolResultContentType.EmbeddedResource; /** Base64-encoded data */ data: string; /** Content type (e.g. `"image/png"`, `"application/pdf"`) */ contentType: string; } +/** + * A reference to a resource stored outside the tool result. + * + * Wraps {@link IContentRef} for lazy-loading large results. + * + * @category Tool Result Content + */ +export interface IToolResultResourceContent extends IContentRef { + type: ToolResultContentType.Resource; +} + /** * Describes a file modification performed by a tool. * - * Clients can use the `beforeURI`/`afterURI` pair to render a diff view. + * Supports creates (only `after`), deletes (only `before`), renames/moves + * (different `uri` in `before` and `after`), and edits (same `uri`, different content). * * @category Tool Result Content */ export interface IToolResultFileEditContent { type: ToolResultContentType.FileEdit; - /** URI of the file content before the edit */ - beforeURI: URI; - /** URI of the file content after the edit */ - afterURI: URI; + /** The file state before the edit. Absent for file creations or for in-place file edits. */ + before?: { + /** URI of the file before the edit */ + uri: URI; + /** Reference to the file content before the edit */ + content: IContentRef; + }; + /** The file state after the edit. Absent for file deletions. */ + after?: { + /** URI of the file after the edit */ + uri: URI; + /** Reference to the file content after the edit */ + content: IContentRef; + }; /** Optional diff display metadata */ diff?: { /** Number of items added (e.g., lines for text files, cells for notebooks) */ @@ -844,16 +873,16 @@ export interface IToolResultFileEditContent { * Content block in a tool result. * * Mirrors the content blocks in MCP `CallToolResult.content`, plus - * `IContentRef` for lazy-loading large results and `IToolResultFileEditContent` - * for file edit diffs (AHP extensions). + * `IToolResultResourceContent` for lazy-loading large results and + * `IToolResultFileEditContent` for file edit diffs (AHP extensions). * * @category Tool Result Content */ export type IToolResultContent = | IToolResultTextContent - | IToolResultBinaryContent - | IToolResultFileEditContent - | IContentRef; + | IToolResultEmbeddedResourceContent + | IToolResultResourceContent + | IToolResultFileEditContent; // ─── Customization Types ───────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index ba3a8c33ecb95..b093358ca6ae9 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -39,14 +39,10 @@ export type { // Command params and results export type { - IBrowseDirectoryParams, - IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, IDispatchActionParams, IDisposeSessionParams, - IFetchContentParams, - IFetchContentResult, IFetchTurnsParams, IFetchTurnsResult, IInitializeParams, @@ -57,10 +53,20 @@ export type { IReconnectReplayResult, IReconnectResult, IReconnectSnapshotResult, + IResourceCopyParams, + IResourceCopyResult, + IResourceDeleteParams, + IResourceDeleteResult, + IResourceListParams, + IResourceListResult, + IResourceMoveParams, + IResourceMoveResult, + IResourceReadParams, + IResourceReadResult, + IResourceWriteParams, + IResourceWriteResult, ISubscribeParams, IUnsubscribeParams, - IWriteFileParams, - IWriteFileResult, } from './protocol/commands.js'; export { ContentEncoding, ReconnectResultType } from './protocol/commands.js'; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 24f5b4a0d7218..6b9ddb22fcaf0 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -56,7 +56,7 @@ export { type IToolDefinition, type ICustomizationRef, type ISessionCustomization, - type IToolResultBinaryContent, + type IToolResultEmbeddedResourceContent as IToolResultBinaryContent, type IToolResultContent, type IToolResultFileEditContent, type IToolResultTextContent, @@ -80,6 +80,23 @@ export { TurnState, } from './protocol/state.js'; +// ---- File edit kind --------------------------------------------------------- + +/** + * The kind of file edit operation. Derived from the presence/absence of + * `before`/`after` in {@link IToolResultFileEditContent}. + */ +export const enum FileEditKind { + /** Content edit (same file URI, different content). */ + Edit = 'edit', + /** File creation (no before state). */ + Create = 'create', + /** File deletion (no after state). */ + Delete = 'delete', + /** File rename/move (different before and after URIs). */ + Rename = 'rename', +} + // ---- Well-known URIs -------------------------------------------------------- /** URI for the root state subscription. */ diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 2f885e0b51e9c..d75cd8c6062dd 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -15,7 +15,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; -import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot, IWriteFileParams, IWriteFileResult } from '../common/state/sessionProtocol.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; @@ -120,14 +120,23 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { nextClientSeq(): number { return this._nextSeq++; } - browseDirectory(uri: URI): Promise { - return this._proxy.browseDirectory(uri); + resourceList(uri: URI): Promise { + return this._proxy.resourceList(uri); } - fetchContent(uri: URI): Promise { - return this._proxy.fetchContent(uri); + resourceRead(uri: URI): Promise { + return this._proxy.resourceRead(uri); } - writeFile(params: IWriteFileParams): Promise { - return this._proxy.writeFile(params); + resourceWrite(params: IResourceWriteParams): Promise { + return this._proxy.resourceWrite(params); + } + resourceCopy(params: IResourceCopyParams): Promise { + return this._proxy.resourceCopy(params); + } + resourceDelete(params: IResourceDeleteParams): Promise { + return this._proxy.resourceDelete(params); + } + resourceMove(params: IResourceMoveParams): Promise { + return this._proxy.resourceMove(params); } async restartAgentHost(): Promise { // Restart is handled by the main process side diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index 30c7870bda439..cef610a4d23f4 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -14,7 +14,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { IFileService } from '../../files/common/files.js'; +import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; @@ -22,9 +22,10 @@ import type { IActionEnvelope, INotification, ISessionAction } from '../common/s import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; +import { AhpErrorCodes } from '../common/state/protocol/errors.js'; import { ContentEncoding } from '../common/state/protocol/commands.js'; import type { ISessionSummary } from '../common/state/sessionState.js'; -import { encodeBase64 } from '../../../base/common/buffer.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; /** * A protocol-level client for a single remote agent host connection. @@ -208,19 +209,31 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * List the contents of a directory on the remote host's filesystem. */ - async browseDirectory(uri: URI): Promise { - return await this._sendRequest('browseDirectory', { uri: uri.toString() }); + async resourceList(uri: URI): Promise { + return await this._sendRequest('resourceList', { uri: uri.toString() }); } /** - * Fetch the content of a file on the remote host's filesystem. + * Read the content of a resource on the remote host. */ - async fetchContent(uri: URI): Promise { - return this._sendRequest('fetchContent', { uri: uri.toString() }); + async resourceRead(uri: URI): Promise { + return this._sendRequest('resourceRead', { uri: uri.toString() }); } - async writeFile(params: ICommandMap['writeFile']['params']): Promise { - return this._sendRequest('writeFile', params); + async resourceWrite(params: ICommandMap['resourceWrite']['params']): Promise { + return this._sendRequest('resourceWrite', params); + } + + async resourceCopy(params: ICommandMap['resourceCopy']['params']): Promise { + return this._sendRequest('resourceCopy', params); + } + + async resourceDelete(params: ICommandMap['resourceDelete']['params']): Promise { + return this._sendRequest('resourceDelete', params); + } + + async resourceMove(params: ICommandMap['resourceMove']['params']): Promise { + return this._sendRequest('resourceMove', params); } private _handleMessage(msg: IProtocolMessage): void { @@ -264,45 +277,64 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } /** - * Handles reverse RPC requests from the server (e.g. browseDirectory, - * fetchContent). Reads from the local file service and sends a response. + * Handles reverse RPC requests from the server (e.g. resourceList, + * resourceRead). Reads from the local file service and sends a response. */ private _handleReverseRequest(id: number, method: string, params: unknown): void { const sendResult = (result: unknown) => { - const response: IJsonRpcResponse = { jsonrpc: '2.0', id, result }; - this._transport.send(response); + this._transport.send({ jsonrpc: '2.0', id, result }); + }; + const sendError = (err: unknown) => { + const fsCode = toFileSystemProviderErrorCode(err instanceof Error ? err : undefined); + let code = -32000; + switch (fsCode) { + case FileSystemProviderErrorCode.FileNotFound: code = AhpErrorCodes.NotFound; break; + case FileSystemProviderErrorCode.NoPermissions: code = AhpErrorCodes.PermissionDenied; break; + case FileSystemProviderErrorCode.FileExists: code = AhpErrorCodes.AlreadyExists; break; + } + this._transport.send({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } }); }; - const sendError = (message: string) => { - const response: IJsonRpcResponse = { jsonrpc: '2.0', id, error: { code: -32000, message } }; - this._transport.send(response); + const handle = (fn: () => Promise) => { + fn().then(sendResult, sendError); }; - const p = params as { uri?: string }; + const p = params as Record; switch (method) { - case 'browseDirectory': { - if (!p.uri) { sendError('Missing uri'); return; } - this._fileService.resolve(URI.parse(p.uri)).then(stat => { - const entries = (stat.children ?? []).map(c => ({ - name: c.name, - type: c.isDirectory ? 'directory' as const : 'file' as const, - })); - sendResult({ entries }); - }).catch(err => sendError(err instanceof Error ? err.message : String(err))); - return; - } - case 'fetchContent': { - if (!p.uri) { sendError('Missing uri'); return; } - this._fileService.readFile(URI.parse(p.uri)).then(content => { - sendResult({ - data: encodeBase64(content.value), - encoding: ContentEncoding.Base64, - }); - }).catch(err => sendError(err instanceof Error ? err.message : String(err))); - return; - } + case 'resourceList': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(async () => { + const stat = await this._fileService.resolve(URI.parse(p.uri as string)); + return { entries: (stat.children ?? []).map(c => ({ name: c.name, type: c.isDirectory ? 'directory' as const : 'file' as const })) }; + }); + case 'resourceRead': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(async () => { + const content = await this._fileService.readFile(URI.parse(p.uri as string)); + return { data: encodeBase64(content.value), encoding: ContentEncoding.Base64 }; + }); + case 'resourceWrite': + if (!p.uri || !p.data) { sendError(new Error('Missing uri or data')); return; } + return handle(async () => { + const writeUri = URI.parse(p.uri as string); + const buf = p.encoding === ContentEncoding.Base64 + ? decodeBase64(p.data as string) + : VSBuffer.fromString(p.data as string); + if (p.createOnly) { + await this._fileService.createFile(writeUri, buf, { overwrite: false }); + } else { + await this._fileService.writeFile(writeUri, buf); + } + return {}; + }); + case 'resourceDelete': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(() => this._fileService.del(URI.parse(p.uri as string), { recursive: !!p.recursive }).then(() => ({}))); + case 'resourceMove': + if (!p.source || !p.destination) { sendError(new Error('Missing source or destination')); return; } + return handle(() => this._fileService.move(URI.parse(p.source as string), URI.parse(p.destination as string), !p.failIfExists).then(() => ({}))); default: this._logService.warn(`[RemoteAgentHostProtocol] Unhandled reverse request: ${method}`); - sendError(`Unknown method: ${method}`); + sendError(new Error(`Unknown method: ${method}`)); } } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6614244c2d9df..6612067ee1799 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -14,7 +14,7 @@ import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IBrowseDirectoryResult, type IDirectoryEntry, type IFetchContentResult, type IStateSnapshot, type IWriteFileParams, type IWriteFileResult } from '../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, TurnState, type IResponsePart, type ISessionSummary, type IToolCallCompletedState, type ITurn } from '../common/state/sessionState.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js'; @@ -264,7 +264,7 @@ export class AgentService extends Disposable implements IAgentService { this._sideEffects.handleAction(action); } - async browseDirectory(uri: URI): Promise { + async resourceList(uri: URI): Promise { let stat; try { stat = await this._fileService.resolve(uri); @@ -360,7 +360,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`); } - async fetchContent(uri: URI): Promise { + async resourceRead(uri: URI): Promise { // Handle session-db: URIs that reference file-edit content stored // in a per-session SQLite database. const dbFields = parseSessionDbUri(uri.toString()); @@ -380,7 +380,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async writeFile(params: IWriteFileParams): Promise { + async resourceWrite(params: IResourceWriteParams): Promise { const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri); let content: VSBuffer; if (params.encoding === ContentEncoding.Base64) { @@ -407,6 +407,56 @@ export class AgentService extends Disposable implements IAgentService { } } + async resourceCopy(params: IResourceCopyParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + try { + await this._fileService.copy(source, destination, !params.failIfExists); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.FileExists) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); + } + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`); + } + } + + async resourceDelete(params: IResourceDeleteParams): Promise { + const fileUri = URI.parse(params.uri); + try { + await this._fileService.del(fileUri, { recursive: params.recursive }); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${fileUri.toString()}`); + } + } + + async resourceMove(params: IResourceMoveParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + try { + await this._fileService.move(source, destination, !params.failIfExists); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.FileExists) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); + } + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`); + } + } + async shutdown(): Promise { this._logService.info('AgentService: shutting down all providers...'); const promises: Promise[] = []; @@ -514,7 +564,7 @@ export class AgentService extends Disposable implements IAgentService { return turns; } - private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { + private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { const sessionUri = URI.parse(fields.sessionUri); const ref = this._sessionDataService.openDatabase(sessionUri); try { @@ -523,6 +573,9 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); } const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent; + if (!bytes) { + throw new ProtocolError(AhpErrorCodes.NotFound, `No ${fields.part} content for: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); + } return { data: new TextDecoder().decode(bytes), encoding: ContentEncoding.Utf8, diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts index 7eee79b096e26..69b401e6069ac 100644 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; -import { ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; +import { FileEditKind, ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; const SESSION_DB_SCHEME = 'session-db'; @@ -144,6 +144,7 @@ export class FileEditTracker { turnId, toolCallId, filePath, + kind: FileEditKind.Edit, beforeContent: beforeBytes, afterContent: afterBytes, addedLines: undefined, @@ -152,8 +153,14 @@ export class FileEditTracker { return { type: ToolResultContentType.FileEdit, - beforeURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before'), - afterURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after'), + before: { + uri: URI.file(filePath).toString(), + content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before') }, + }, + after: { + uri: URI.file(filePath).toString(), + content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after') }, + }, }; } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index f9bfbf1470d45..4a651512eb821 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -186,10 +186,22 @@ export async function mapSessionEvents( const edits = storedEdits?.get(d.toolCallId); if (edits) { for (const edit of edits) { + const beforeUri = edit.kind === 'rename' && edit.originalPath + ? URI.file(edit.originalPath).toString() + : URI.file(edit.filePath).toString(); + const afterUri = URI.file(edit.filePath).toString(); + const hasBefore = edit.kind !== 'create'; + const hasAfter = edit.kind !== 'delete'; content.push({ type: ToolResultContentType.FileEdit, - beforeURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before'), - afterURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after'), + before: hasBefore ? { + uri: beforeUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, + } : undefined, + after: hasAfter ? { + uri: afterUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, + } : undefined, diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) ? { added: edit.addedLines, removed: edit.removedLines } : undefined, diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 07f4fb3a3a042..311d823078540 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -237,8 +237,11 @@ export class ProtocolServerHandler extends Disposable { this._onDidChangeConnectionCount.fire(this._clients.size); disposables.add(this._clientFileSystemProvider.registerAuthority(params.clientId, { - browseDirectory: (uri) => this._sendReverseRequest(params.clientId, 'browseDirectory', { uri: uri.toString() }), - fetchContent: (uri) => this._sendReverseRequest(params.clientId, 'fetchContent', { uri: uri.toString() }), + resourceList: (uri) => this._sendReverseRequest(params.clientId, 'resourceList', { uri: uri.toString() }), + resourceRead: (uri) => this._sendReverseRequest(params.clientId, 'resourceRead', { uri: uri.toString() }), + resourceWrite: (params_) => this._sendReverseRequest(params.clientId, 'resourceWrite', params_), + resourceDelete: (params_) => this._sendReverseRequest(params.clientId, 'resourceDelete', params_), + resourceMove: (params_) => this._sendReverseRequest(params.clientId, 'resourceMove', params_), })); @@ -369,8 +372,8 @@ export class ProtocolServerHandler extends Disposable { await this._agentService.disposeSession(URI.parse(params.session)); return null; }, - writeFile: async (_client, params) => { - return this._agentService.writeFile(params); + resourceWrite: async (_client, params) => { + return this._agentService.resourceWrite(params); }, listSessions: async () => { const sessions = await this._agentService.listSessions(); @@ -407,11 +410,20 @@ export class ProtocolServerHandler extends Disposable { hasMore: startIndex > 0, }; }, - browseDirectory: async (_client, params) => { - return this._agentService.browseDirectory(URI.parse(params.uri)); + resourceList: async (_client, params) => { + return this._agentService.resourceList(URI.parse(params.uri)); }, - fetchContent: async (_client, params) => { - return this._agentService.fetchContent(URI.parse(params.uri)); + resourceRead: async (_client, params) => { + return this._agentService.resourceRead(URI.parse(params.uri)); + }, + resourceCopy: async (_client, params) => { + return this._agentService.resourceCopy(params); + }, + resourceDelete: async (_client, params) => { + return this._agentService.resourceDelete(params); + }, + resourceMove: async (_client, params) => { + return this._agentService.resourceMove(params); }, }; diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 77b72f26a4a25..aab5aff8025c4 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -51,6 +51,29 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ value TEXT NOT NULL )`, }, + { + version: 3, + sql: [ + // Recreate file_edits with new columns: edit_type, original_path, + // and nullable before_content/after_content. + `CREATE TABLE file_edits_v3 ( + turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + tool_call_id TEXT NOT NULL, + file_path TEXT NOT NULL, + edit_type TEXT NOT NULL DEFAULT 'edit', + original_path TEXT, + before_content BLOB, + after_content BLOB, + added_lines INTEGER, + removed_lines INTEGER, + PRIMARY KEY (tool_call_id, file_path) + )`, + `INSERT INTO file_edits_v3 (turn_id, tool_call_id, file_path, edit_type, before_content, after_content, added_lines, removed_lines) + SELECT turn_id, tool_call_id, file_path, 'edit', before_content, after_content, added_lines, removed_lines FROM file_edits`, + `DROP TABLE file_edits`, + `ALTER TABLE file_edits_v3 RENAME TO file_edits`, + ].join(';\n'), + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -242,14 +265,16 @@ export class SessionDatabase implements ISessionDatabase { await dbRun( db, `INSERT OR REPLACE INTO file_edits - (turn_id, tool_call_id, file_path, before_content, after_content, added_lines, removed_lines) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + (turn_id, tool_call_id, file_path, edit_type, original_path, before_content, after_content, added_lines, removed_lines) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ edit.turnId, edit.toolCallId, edit.filePath, - Buffer.from(edit.beforeContent), - Buffer.from(edit.afterContent), + edit.kind, + edit.originalPath ?? null, + edit.beforeContent ? Buffer.from(edit.beforeContent) : null, + edit.afterContent ? Buffer.from(edit.afterContent) : null, edit.addedLines ?? null, edit.removedLines ?? null, ], @@ -265,7 +290,7 @@ export class SessionDatabase implements ISessionDatabase { const placeholders = toolCallIds.map(() => '?').join(','); const rows = await dbAll( db, - `SELECT turn_id, tool_call_id, file_path, added_lines, removed_lines + `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines FROM file_edits WHERE tool_call_id IN (${placeholders}) ORDER BY rowid`, @@ -275,6 +300,8 @@ export class SessionDatabase implements ISessionDatabase { turnId: row.turn_id as string, toolCallId: row.tool_call_id as string, filePath: row.file_path as string, + kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit', + originalPath: row.original_path as string | undefined ?? undefined, addedLines: row.added_lines as number | undefined ?? undefined, removedLines: row.removed_lines as number | undefined ?? undefined, })); @@ -294,8 +321,8 @@ export class SessionDatabase implements ISessionDatabase { return undefined; } return { - beforeContent: toUint8Array(row.before_content), - afterContent: toUint8Array(row.after_content), + beforeContent: row.before_content ? toUint8Array(row.before_content) : undefined, + afterContent: row.after_content ? toUint8Array(row.after_content) : undefined, }; }); } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index cec34d9738b00..3b41fa674cfa1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -375,20 +375,20 @@ suite('AgentService (node dispatcher)', () => { }); }); - // ---- browseDirectory ------------------------------------------------ + // ---- resourceList ------------------------------------------------ - suite('browseDirectory', () => { + suite('resourceList', () => { test('throws when the directory does not exist', async () => { await assert.rejects( - () => service.browseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })), + () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })), /Directory not found/, ); }); test('throws when the target is not a directory', async () => { await assert.rejects( - () => service.browseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })), + () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })), /Not a directory/, ); }); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts index dec81f59f3e4e..8433372ab0f8d 100644 --- a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -51,14 +51,14 @@ suite('FileEditTracker', () => { assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit); // URIs are parseable session-db: URIs - const beforeFields = parseSessionDbUri(fileEdit.beforeURI); + const beforeFields = parseSessionDbUri(fileEdit.before!.content.uri); assert.ok(beforeFields); assert.strictEqual(beforeFields.sessionUri, 'copilot:/test-session'); assert.strictEqual(beforeFields.toolCallId, 'tc-1'); assert.strictEqual(beforeFields.filePath, '/workspace/test.txt'); assert.strictEqual(beforeFields.part, 'before'); - const afterFields = parseSessionDbUri(fileEdit.afterURI); + const afterFields = parseSessionDbUri(fileEdit.after!.content.uri); assert.ok(afterFields); assert.strictEqual(afterFields.part, 'after'); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 034dbb4943550..3472a3019a717 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agentService.js'; -import { ToolResultContentType } from '../../common/state/sessionState.js'; +import { FileEditKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; @@ -102,6 +102,7 @@ suite('mapSessionEvents', () => { turnId: 'turn-1', toolCallId: 'tc-edit', filePath: '/workspace/file.ts', + kind: FileEditKind.Edit, beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), addedLines: 3, @@ -131,8 +132,8 @@ suite('mapSessionEvents', () => { assert.strictEqual(content[1].type, ToolResultContentType.FileEdit); // File edit URIs should be parseable - const fileEdit = content[1] as { beforeURI: string; afterURI: string; diff?: { added?: number; removed?: number } }; - const beforeFields = parseSessionDbUri(fileEdit.beforeURI); + const fileEdit = content[1] as { before: { uri: any; content: { uri: any } }; after: { uri: any; content: { uri: any } }; diff?: { added?: number; removed?: number } }; + const beforeFields = parseSessionDbUri(fileEdit.before.content.uri); assert.ok(beforeFields); assert.strictEqual(beforeFields.toolCallId, 'tc-edit'); assert.strictEqual(beforeFields.filePath, '/workspace/file.ts'); @@ -147,6 +148,7 @@ suite('mapSessionEvents', () => { turnId: 'turn-1', toolCallId: 'tc-multi', filePath: '/workspace/a.ts', + kind: FileEditKind.Edit, beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('a'), addedLines: undefined, @@ -156,6 +158,7 @@ suite('mapSessionEvents', () => { turnId: 'turn-1', toolCallId: 'tc-multi', filePath: '/workspace/b.ts', + kind: FileEditKind.Edit, beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('b'), addedLines: undefined, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 5dc16e87966af..64a77cd26b53c 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -10,10 +10,10 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../../common/agentService.js'; -import { IFetchContentResult } from '../../common/state/protocol/commands.js'; +import { IResourceReadResult } from '../../common/state/protocol/commands.js'; import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; -import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IBrowseDirectoryResult, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IStateSnapshot, type IWriteFileParams, type IWriteFileResult } from '../../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IResourceListResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; @@ -105,8 +105,8 @@ class MockAgentService implements IAgentService { async authenticate(_params: IAuthenticateParams): Promise { return { authenticated: true }; } async refreshModels(): Promise { } async listAgents(): Promise { return []; } - async writeFile(_params: IWriteFileParams): Promise { return {}; } - async browseDirectory(uri: URI): Promise { + async resourceWrite(_params: IResourceWriteParams): Promise { return {}; } + async resourceList(uri: URI): Promise { this.browsedUris.push(uri); const error = this.browseErrors.get(uri.toString()); if (error) { @@ -119,9 +119,12 @@ class MockAgentService implements IAgentService { ], }; } - async fetchContent(_uri: URI): Promise { + async resourceRead(_uri: URI): Promise { throw new Error('Not implemented'); } + async resourceCopy(): Promise<{}> { return {}; } + async resourceDelete(): Promise<{}> { return {}; } + async resourceMove(): Promise<{}> { return {}; } dispose(): void { this._onDidAction.dispose(); @@ -386,13 +389,13 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser'); }); - test('browseDirectory routes to side effect handler', async () => { + test('resourceList routes to side effect handler', async () => { const transport = connectClient('client-browse'); transport.sent.length = 0; const dirUri = URI.file('/home/user/project').toString(); const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri })); + transport.simulateMessage(request(2, 'resourceList', { uri: dirUri })); const resp = await responsePromise; assert.strictEqual(agentService.browsedUris.length, 1); @@ -407,14 +410,14 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(result.entries[1].type, 'file'); }); - test('browseDirectory returns a JSON-RPC error when the target is invalid', async () => { + test('resourceList returns a JSON-RPC error when the target is invalid', async () => { const transport = connectClient('client-browse-error'); transport.sent.length = 0; const dirUri = URI.file('/missing').toString(); agentService.browseErrors.set(URI.file('/missing').toString(), new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`)); const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri })); + transport.simulateMessage(request(2, 'resourceList', { uri: dirUri })); const resp = await responsePromise as { error?: { code: number; message: string } }; assert.ok(resp?.error); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index fbf66f35f7b02..23d26104eb73a 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; +import { FileEditKind } from '../../common/state/sessionState.js'; import type { Database } from '@vscode/sqlite3'; suite('SessionDatabase', () => { @@ -125,6 +126,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), @@ -136,7 +138,9 @@ suite('SessionDatabase', () => { assert.deepStrictEqual(edits, [{ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', + originalPath: undefined, addedLines: 5, removedLines: 2, }]); @@ -149,6 +153,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new TextEncoder().encode('a-before'), afterContent: new TextEncoder().encode('a-after'), @@ -158,6 +163,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/b.ts', beforeContent: new TextEncoder().encode('b-before'), afterContent: new TextEncoder().encode('b-after'), @@ -178,6 +184,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('hello'), @@ -187,6 +194,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-2', + kind: FileEditKind.Edit, filePath: '/workspace/b.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('world'), @@ -222,6 +230,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('v1'), afterContent: new TextEncoder().encode('v1-after'), @@ -231,6 +240,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('v2'), afterContent: new TextEncoder().encode('v2-after'), @@ -254,6 +264,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), @@ -281,6 +292,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-bin', + kind: FileEditKind.Edit, filePath: '/workspace/image.png', beforeContent: new Uint8Array(0), afterContent: binary, @@ -300,6 +312,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'auto-turn', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/x', beforeContent: new Uint8Array(0), afterContent: new Uint8Array(0), @@ -330,6 +343,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), @@ -353,6 +367,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('a'), @@ -362,6 +377,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-2', toolCallId: 'tc-2', + kind: FileEditKind.Edit, filePath: '/workspace/b.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('b'), diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 1615924a5cfe6..8f9c2694c652e 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -8,6 +8,14 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IPlaywrightService = createDecorator('playwrightService'); +export interface IInvokeFunctionResult { + result?: unknown; + error?: string; + summary: string; + /** When present the function did not complete within the timeout. Pass this ID to {@link IPlaywrightService.waitForDeferredResult} to keep waiting. */ + deferredResultId?: string; +} + /** * A service for using Playwright to connect to and automate the integrated browser. * @@ -74,12 +82,30 @@ export interface IPlaywrightService { /** * Run a function with access to a Playwright page and return a result for tool output, including error handling. * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. + * + * When {@link timeoutMs} is provided, the call races against that timeout. + * If the timeout fires before the function completes, or the function is otherwise interrupted, + * the in-flight promise is stored as a *deferred result* and the returned object includes a + * {@link deferredResultId} that can be passed to {@link waitForDeferredResult} to resume waiting. + * When {@link timeoutMs} is omitted the function runs to completion with no deferral. + * * @param pageId The browser view ID identifying the page to operate on. * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. * @param args Additional arguments to pass to the function after the `page` object. - * @returns The result of the function execution, including a page summary. + * @param timeoutMs Maximum time (in ms) to wait for the function to complete before deferring. When omitted the call awaits indefinitely. + * @returns The result of the function execution, including a page summary and optionally a deferredResultId if the call did not complete. + */ + invokeFunction(pageId: string, fnDef: string, args?: unknown[], timeoutMs?: number): Promise; + + /** + * Continue waiting for a previously deferred function invocation. + * + * @param deferredResultId The ID returned from a timed-out {@link invokeFunction} call. + * @param timeoutMs Maximum time (in ms) to wait before returning a deferred result again. + * @returns The same shape as {@link invokeFunction}. If the result is still not + * available after the timeout, {@link deferredResultId} is returned again. */ - invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; + waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise; /** * Responds to a file chooser dialog on the given page. diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts index 592560e5f1d8d..4bd3276745cab 100644 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -27,6 +27,20 @@ // ### ### // ####################################################################### + // Ctrl/Cmd keybindings that correspond to native editing shortcuts and should be handled by the browser / OS and not forwarded to the workbench. + const nativeCtrlCmdKeybindings = { + mac: { + always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'backspace', 'delete']), + noShift: new Set(['a', 'c', 'v', 'x', 'z']), + withShift: new Set(['v', 'z']), + }, + nonMac: { + always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'home', 'end', 'backspace', 'delete']), + noShift: new Set(['a', 'c', 'v', 'x', 'z', 'y']), + withShift: new Set(['v', 'z']), + } + }; + // Listen for keydown events that the page did not handle and forward them for shortcut handling. window.addEventListener('keydown', (event) => { // Require that the event is trusted -- i.e. user-initiated. @@ -51,6 +65,11 @@ return; } + // Never handle plain modifier key presses as keybindings + if (event.key === 'Control' || event.key === 'Shift' || event.key === 'Alt' || event.key === 'Meta') { + return; + } + const isMac = navigator.platform.indexOf('Mac') >= 0; // Alt+Key special character handling (Alt + Numpad keys on Windows/Linux, Alt + any key on Mac) @@ -60,18 +79,20 @@ } } - // Allow native shortcuts (copy, paste, cut, undo, redo, select all) to be handled by the browser + // Allow native shortcuts to be handled by the browser const ctrlCmd = isMac ? event.metaKey : event.ctrlKey; if (ctrlCmd && !event.altKey) { const key = event.key.toLowerCase(); - if (!event.shiftKey && (key === 'a' || key === 'c' || key === 'v' || key === 'x' || key === 'z')) { - return; - } - if (event.shiftKey && (key === 'v' || key === 'z')) { + const keySetsToCheck = [ + nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'].always, + nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'][event.shiftKey ? 'withShift' : 'noShift'], + ]; + if (keySetsToCheck.some(set => set.has(key))) { return; } - // Ctrl+Y is redo on Windows/Linux - if (!event.shiftKey && key === 'y' && !isMac) { + + // Emoji picker on Mac + if (isMac && event.ctrlKey && !event.shiftKey && key === ' ') { return; } } diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index ebdbdb3bf3e83..a2379fccf2810 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -163,7 +163,7 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { * Detach from the Electron debugger */ private detachElectronDebugger(): void { - if (!this._electronDebugger.isAttached()) { + if (this.view.webContents.isDestroyed() || !this._electronDebugger.isAttached()) { return; } diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 70fba85e7d0a7..91f512d00634e 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -3,15 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { DeferredPromise } from '../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { DeferredPromise, disposableTimeout, raceTimeout } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; -import { IPlaywrightService } from '../common/playwrightService.js'; +import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; -import { PlaywrightTab } from './playwrightTab.js'; +import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; +import { generateUuid } from '../../../base/common/uuid.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, Page } from 'playwright-core'; @@ -29,6 +30,8 @@ declare module 'playwright-core' { } } +const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes + /** * Shared-process implementation of {@link IPlaywrightService}. * @@ -45,6 +48,12 @@ export class PlaywrightService extends Disposable implements IPlaywrightService private _browser: Browser | undefined; private _initPromise: Promise | undefined; + /** In-flight deferred results keyed by their generated ID. */ + private readonly _deferredResults = this._register(new DisposableMap; + } & IDisposable>()); + constructor( private readonly windowId: number, private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, @@ -157,29 +166,87 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._pages.runAgainstPage(pageId, (page) => fn(page, args)); } - async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { + private async invokeFunctionWithDeferral(pageId: string, fnDef: string, args: unknown[], timeoutMs: number): Promise { + await this.initialize(); + + const vm = await import('vm'); + const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + + return this._runWithDeferral(pageId, (page) => fn(page, args ?? []), timeoutMs); + } + + async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise { this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); + if (timeoutMs !== undefined) { + return this.invokeFunctionWithDeferral(pageId, fnDef, args, timeoutMs); + } + + let result, error; try { - let result; - try { - result = await this.invokeFunctionRaw(pageId, fnDef, ...args); - } catch (err: unknown) { - result = err instanceof Error ? err.message : String(err); - } + result = await this.invokeFunctionRaw(pageId, fnDef, ...args); + } catch (err: unknown) { + error = err instanceof Error ? err.message : String(err); + } - let summary; - try { - summary = await this._pages.getSummary(pageId); - } catch (err: unknown) { - summary = err instanceof Error ? err.message : String(err); - } - return { result, summary }; + const summary = await this._pages.getSummary(pageId); + + return { result, error, summary }; + } + + async waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise { + const entry = this._deferredResults.get(deferredResultId); + if (!entry) { + throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`); + } + + const { pageId, promise } = entry; + // Remove eagerly — _runWithDeferral will re-insert if interrupted again. + this._deferredResults.deleteAndDispose(deferredResultId); + + // The callback ignores the page param since execution is already in-flight. + return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId); + } + + /** + * Run a callback against a page with deferred result support. + */ + private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise, timeoutMs: number, existingDeferredId?: string): Promise { + const effectiveTimeout = timeoutMs; + + // Start execution via safeRunAgainstPage, but capture the raw promise + // independently so it can be deferred if a dialog or timeout interrupts. + const deferred = new DeferredPromise(); + const wrappedPromise = this._pages.runAgainstPage(pageId, async (page) => { + const promise = callback(page); + promise.catch(() => { /* prevent unhandled rejection if deferred */ }); + deferred.settleWith(promise); + return promise; + }); + + let result, error; + let interrupted = false; + + try { + result = await raceTimeout(wrappedPromise, effectiveTimeout, () => { interrupted = true; }); } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightService] Script execution failed:', errorMessage); - throw err; + if (err instanceof DialogInterruptedError) { + interrupted = true; + } + error = err instanceof Error ? err.message : String(err); } + + let deferredResultId: string | undefined; + if (interrupted) { + deferredResultId = existingDeferredId ?? generateUuid(); + const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS); + this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, dispose: () => cleanup.dispose() }); + + this.logService.info(`[PlaywrightService] Execution interrupted, deferred as ${deferredResultId}`); + } + + const summary = await this._pages.getSummary(pageId); + return { result, error, summary, deferredResultId }; } async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 0a73676455fe1..ceddf207a6e78 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -9,10 +9,24 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; +type IAiAriaSnapshotOptions = NonNullable[0]> & { _track?: string }; + declare module 'playwright-core' { interface Page { - // A hidden Playwright method that returns an AI-friendly snapshot of the page. - _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; + // We defined this here to be able to use the unofficial `_track` option + ariaSnapshot(options?: IAiAriaSnapshotOptions): Promise; + } +} + +/** + * Thrown when a dialog (alert, confirm, prompt) opens while a page action is + * running. The caller should defer the underlying promise and let the agent + * handle the dialog before retrying. + */ +export class DialogInterruptedError extends Error { + constructor() { + super('Action was interrupted by a dialog'); + this.name = 'DialogInterruptedError'; } } @@ -152,7 +166,7 @@ export class PlaywrightTab { return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { if (!actionDidComplete) { // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. - throw new Error('Action was interrupted by a dialog'); + throw new DialogInterruptedError(); } return result!; }); @@ -165,7 +179,7 @@ export class PlaywrightTab { this._needsFullSnapshot = false; } - const snapshotFromPage = await this.safeRunAgainstPage((page) => page._snapshotForAI({ track: 'response' })).catch(() => { + const snapshotFromPage = await this.safeRunAgainstPage((page) => this.getAiSnapshot(page, full)).catch(() => { this._needsFullSnapshot = true; return undefined; }); @@ -174,7 +188,7 @@ export class PlaywrightTab { const logs = this._logs; this._logs = []; - const snapshot = (full ? snapshotFromPage?.full : snapshotFromPage?.incremental ?? snapshotFromPage?.full)?.trim() ?? ''; + const snapshot = snapshotFromPage?.trim() ?? ''; return [ ...(title ? [`Page Title: ${title}`] : []), @@ -185,10 +199,18 @@ export class PlaywrightTab { `Recent events:`, ...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`) ] : []), - ...(snapshot ? ['Snapshot:', snapshot] : []) + `Snapshot: ${snapshotFromPage ? snapshot ? `\n${snapshot}` : '' : ''}`, ].join('\n'); } + private getAiSnapshot(page: playwright.Page, full: boolean): Promise { + const options: IAiAriaSnapshotOptions = { mode: 'ai' }; + if (!full) { + options._track = 'response'; + } + return page.ariaSnapshot(options); + } + private async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { const requests: playwright.Request[] = []; diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index bd8d5bbc1c104..a15c7e5e49619 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -18,7 +18,8 @@ import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/ // Register Icons const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); -const sidebarToggleIcon = registerIcon('agent-sidebar-toggle', Codicon.tasklist, localize('agentSidebarToggleIcon', "Icon to toggle the sessions sidebar.")); +const sidebarToggleClosedIcon = registerIcon('agent-sidebar-toggle-closed', Codicon.layoutSidebarLeftOff, localize('agentSidebarToggleClosedIcon', "Icon for the sessions sidebar when closed.")); +const sidebarToggleOpenIcon = registerIcon('agent-sidebar-toggle-open', Codicon.layoutSidebarLeft, localize('agentSidebarToggleOpenIcon', "Icon for the sessions sidebar when open.")); class ToggleSidebarVisibilityAction extends Action2 { @@ -28,9 +29,10 @@ class ToggleSidebarVisibilityAction extends Action2 { super({ id: ToggleSidebarVisibilityAction.ID, title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: sidebarToggleIcon, + icon: sidebarToggleClosedIcon, toggled: { condition: SideBarVisibleContext, + icon: sidebarToggleOpenIcon, }, metadata: { description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 14cd046fcd88c..2d59d94d388e9 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -116,6 +116,13 @@ box-sizing: border-box; } +/* Hide shared chat-session option-group pickers in the sessions app active chat UI. + * The sessions workbench provides its own new-session configuration controls and + * should not surface the shared workbench chat session pickers here. */ +.agent-sessions-workbench .interactive-session .chat-input-toolbars .chat-sessionPicker-container { + display: none; +} + /* ---- Modal Editor Block ---- */ .agent-sessions-workbench .monaco-modal-editor-block { diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index d349090f5ee27..5e6550fa6f936 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -15,7 +15,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; -import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, derivedObservableWithCache, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/path.js'; import { IResourceNode, ResourceTree } from '../../../../base/common/resourceTree.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; @@ -30,7 +30,7 @@ import { MenuId, Action2, MenuItemAction, registerAction2 } from '../../../../pl import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownActionProvider } from '../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -66,7 +66,7 @@ import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeRevie import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; import { GitDiffChange, IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { CIStatusWidget } from './checksWidget.js'; -import { arrayEqualsC } from '../../../../base/common/equals.js'; +import { arrayEqualsC, structuralEquals } from '../../../../base/common/equals.js'; import { GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../sessions/common/sessionData.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; @@ -527,6 +527,16 @@ export class ChangesViewPane extends ViewPane { private splitView: SplitView | undefined; private splitViewContainer: HTMLElement | undefined; + private readonly isMergeBaseBranchProtectedContextKey: IContextKey; + private readonly isolationModeContextKey: IContextKey; + private readonly hasGitRepositoryContextKey: IContextKey; + private readonly hasChangesContextKey: IContextKey; + private readonly hasIncomingChangesContextKey: IContextKey; + private readonly hasOpenPullRequestContextKey: IContextKey; + private readonly hasOutgoingChangesContextKey: IContextKey; + private readonly hasPullRequestContextKey: IContextKey; + private readonly hasUncommittedChangesContextKey: IContextKey; + private readonly renderDisposables = this._register(new DisposableStore()); // Track current body dimensions for list layout @@ -558,6 +568,17 @@ export class ChangesViewPane extends ViewPane { this.viewModel = this.instantiationService.createInstance(ChangesViewModel); this._register(this.viewModel); + // Context keys + this.isMergeBaseBranchProtectedContextKey = isMergeBaseBranchProtectedContextKey.bindTo(this.scopedContextKeyService); + this.isolationModeContextKey = isolationModeContextKey.bindTo(this.scopedContextKeyService); + this.hasGitRepositoryContextKey = hasGitRepositoryContextKey.bindTo(this.scopedContextKeyService); + this.hasChangesContextKey = ChatContextKeys.hasAgentSessionChanges.bindTo(this.scopedContextKeyService); + this.hasIncomingChangesContextKey = hasIncomingChangesContextKey.bindTo(this.scopedContextKeyService); + this.hasOutgoingChangesContextKey = hasOutgoingChangesContextKey.bindTo(this.scopedContextKeyService); + this.hasUncommittedChangesContextKey = hasUncommittedChangesContextKey.bindTo(this.scopedContextKeyService); + this.hasPullRequestContextKey = hasPullRequestContextKey.bindTo(this.scopedContextKeyService); + this.hasOpenPullRequestContextKey = hasOpenPullRequestContextKey.bindTo(this.scopedContextKeyService); + // Version mode this._register(bindContextKey(changesVersionModeContextKey, this.scopedContextKeyService, reader => { return this.viewModel.versionModeObs.read(reader); @@ -775,7 +796,6 @@ export class ChangesViewPane extends ViewPane { const isLoadingChangesObs = derived(reader => { // If there is a git repository, wait for the repository to be opened first, // as there are many context keys that depend on the repository information. - // We want to avoid flickering of the actions. const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); if (hasGitRepository && this.viewModel.activeSessionRepositoryObs.read(reader) === undefined) { return true; @@ -860,57 +880,12 @@ export class ChangesViewPane extends ViewPane { if (this.actionsContainer) { dom.clearNode(this.actionsContainer); - let lastHasChanges = false; - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { - if (isLoadingChangesObs.read(reader)) { - return lastHasChanges; - } - const { files } = topLevelStats.read(reader); - lastHasChanges = files > 0; - return lastHasChanges; - })); - - this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => { - const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader); - return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error; - })); - - this.renderDisposables.add(bindContextKey(isolationModeContextKey, this.scopedContextKeyService, reader => { - return this.viewModel.activeSessionIsolationModeObs.read(reader); - })); - - this.renderDisposables.add(bindContextKey(hasGitRepositoryContextKey, this.scopedContextKeyService, reader => { - return this.viewModel.activeSessionHasGitRepositoryObs.read(reader); - })); - - this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - return activeSession?.workspace.read(reader)?.repositories[0]?.baseBranchProtected === true; - })); - - this.renderDisposables.add(bindContextKey(hasPullRequestContextKey, this.scopedContextKeyService, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - const gitHubInfo = activeSession?.gitHubInfo.read(reader); - return gitHubInfo?.pullRequest?.uri !== undefined; - })); - - this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - const gitHubInfo = activeSession?.gitHubInfo.read(reader); - if (gitHubInfo?.pullRequest?.uri === undefined) { - return false; - } - const iconId = gitHubInfo.pullRequest.icon?.id; - return iconId !== undefined && - (iconId === Codicon.gitPullRequestDraft.id || - iconId === Codicon.gitPullRequest.id); - })); + // Bind context keys + this._bindContextKeys(isLoadingChangesObs, topLevelStats); - this.renderDisposables.add(bindContextKey(hasIncomingChangesContextKey, this.scopedContextKeyService, reader => { - const repository = this.viewModel.activeSessionRepositoryObs.read(reader); - const repositoryState = repository?.state.read(reader); - return (repositoryState?.HEAD?.behind ?? 0) > 0; - })); + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); + const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); + this.renderDisposables.add(scopedInstantiationService); const outgoingChangesObs = derived(reader => { const repository = this.viewModel.activeSessionRepositoryObs.read(reader); @@ -919,25 +894,6 @@ export class ChangesViewPane extends ViewPane { return repositoryState?.HEAD?.ahead ?? 0; }); - this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { - const outgoingChanges = outgoingChangesObs.read(reader); - return outgoingChanges > 0; - })); - - this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, reader => { - const repository = this.viewModel.activeSessionRepositoryObs.read(reader); - const repositoryState = repository?.state.read(reader); - - return (repositoryState?.mergeChanges.length ?? 0) > 0 || - (repositoryState?.indexChanges.length ?? 0) > 0 || - (repositoryState?.workingTreeChanges.length ?? 0) > 0 || - (repositoryState?.untrackedChanges.length ?? 0) > 0; - })); - - const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); - const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); - this.renderDisposables.add(scopedInstantiationService); - this.renderDisposables.add(autorun(reader => { const outgoingChanges = outgoingChangesObs.read(reader); const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); @@ -1187,6 +1143,105 @@ export class ChangesViewPane extends ViewPane { })); } + private _bindContextKeys(isLoadingChangesObs: IObservable, topLevelStats: IObservable<{ files: number }>): void { + // Request in progress (can be updated independently since it only affects action enablement, and not visibility) + this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => { + const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader); + return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error; + })); + + type ContextKeys = { + readonly hasChanges: boolean; + readonly isolationMode: IsolationMode; + readonly hasGitRepository: boolean; + readonly isMergeBaseBranchProtected: boolean; + readonly hasPullRequest: boolean; + readonly hasOpenPullRequest: boolean; + readonly hasIncomingChanges: boolean; + readonly hasOutgoingChanges: boolean; + readonly hasUncommittedChanges: boolean; + }; + + // The following context keys have to be updated together based on the combined entries + // to avoid flickering of actions when switching between sessions and changes are loading + const contextKeysRawObs = derivedObservableWithCache( + this, (reader, lastValue) => { + const isLoading = isLoadingChangesObs.read(reader); + if (isLoading) { + return lastValue; + } + + const activeSession = this.sessionManagementService.activeSession.read(reader); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + + // Changes state + const { files } = topLevelStats.read(reader); + const hasChanges = files > 0; + + // Session state + const isolationMode = this.viewModel.activeSessionIsolationModeObs.read(reader); + const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); + const isMergeBaseBranchProtected = activeSession?.workspace.read(reader)?.repositories[0]?.baseBranchProtected === true; + + // Pull request state + const gitHubInfo = activeSession?.gitHubInfo.read(reader); + const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; + const hasOpenPullRequest = hasPullRequest && + (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || + gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id); + + // Repository state + const repositoryState = repository?.state.read(reader); + const hasIncomingChanges = (repositoryState?.HEAD?.behind ?? 0) > 0; + const hasOutgoingChanges = (repositoryState?.HEAD?.ahead ?? 0) > 0; + const hasUncommittedChanges = (repositoryState?.mergeChanges.length ?? 0) > 0 || + (repositoryState?.indexChanges.length ?? 0) > 0 || + (repositoryState?.workingTreeChanges.length ?? 0) > 0 || + (repositoryState?.untrackedChanges.length ?? 0) > 0; + + return { + hasChanges, + isolationMode, + hasGitRepository, + isMergeBaseBranchProtected, + hasPullRequest, + hasOpenPullRequest, + hasIncomingChanges, + hasOutgoingChanges, + hasUncommittedChanges, + }; + }); + + // Create a derived observable that only emits when the + // context keys actually change to avoid unnecessary updates + const contextKeysObs = derivedOpts({ + equalsFn: structuralEquals + }, reader => { + const contextKeysRaw = contextKeysRawObs.read(reader); + return contextKeysRaw; + }); + + // Bulk update the context keys + this.renderDisposables.add(autorun(reader => { + const contextKeys = contextKeysObs.read(reader); + if (!contextKeys) { + return; + } + + this.scopedContextKeyService.bufferChangeEvents(() => { + this.hasChangesContextKey.set(contextKeys.hasChanges); + this.isMergeBaseBranchProtectedContextKey.set(contextKeys.isMergeBaseBranchProtected); + this.isolationModeContextKey.set(contextKeys.isolationMode); + this.hasGitRepositoryContextKey.set(contextKeys.hasGitRepository); + this.hasPullRequestContextKey.set(contextKeys.hasPullRequest); + this.hasOpenPullRequestContextKey.set(contextKeys.hasOpenPullRequest); + this.hasIncomingChangesContextKey.set(contextKeys.hasIncomingChanges); + this.hasOutgoingChangesContextKey.set(contextKeys.hasOutgoingChanges); + this.hasUncommittedChangesContextKey.set(contextKeys.hasUncommittedChanges); + }); + })); + } + /** Layout the tree within its SplitView pane. */ private _layoutTreeInPane(paneHeight: number): void { if (!this.tree) { diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 3f05db616851b..3425f9f2c4df8 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -114,7 +114,7 @@ export class SessionTypePicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -129,7 +129,10 @@ export class SessionTypePicker extends Disposable { labelSpan.textContent = modeLabel; const hasMultipleTypes = this._sessionTypes.length > 1; - this._slotElement?.classList.toggle('disabled', !hasMultipleTypes); + dom.setVisibility(hasMultipleTypes, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!hasMultipleTypes)); + this._triggerElement.tabIndex = hasMultipleTypes ? 0 : -1; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); } } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts new file mode 100644 index 0000000000000..af522c20d3372 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IActiveSession, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionType } from '../../../sessions/browser/sessionsProvider.js'; +import { SessionStatus } from '../../../sessions/common/sessionData.js'; +import { SessionTypePicker } from '../../browser/sessionTypePicker.js'; + +function createActiveSession(sessionType: string): IActiveSession { + const chat = { + resource: URI.parse(`test:///chat/${sessionType}`), + createdAt: new Date(), + title: constObservable('Chat'), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Untitled), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + }; + + return { + sessionId: `provider:${sessionType}`, + resource: URI.parse(`test:///session/${sessionType}`), + providerId: 'provider', + sessionType, + icon: Codicon.copilot, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable('Session'), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Untitled), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + gitHubInfo: constObservable(undefined), + chats: constObservable([chat]), + mainChat: chat, + activeChat: constObservable(chat), + }; +} + +suite('SessionTypePicker', () => { + + const disposables = new DisposableStore(); + let sessionTypes: ISessionType[]; + let activeSession: ReturnType>; + let instantiationService: TestInstantiationService; + + setup(() => { + sessionTypes = []; + activeSession = observableValue('activeSession', undefined); + instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } }); + instantiationService.stub(ISessionsManagementService, { + activeSession, + getSessionTypes: () => sessionTypes, + setSessionType: () => { + throw new Error('Not implemented'); + }, + }); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('hides the picker when only one session type is available', () => { + sessionTypes = [{ id: 'copilotcli', label: 'Copilot CLI', icon: Codicon.copilot }]; + activeSession.set(createActiveSession('copilotcli'), undefined); + + const picker = disposables.add(instantiationService.createInstance(SessionTypePicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + assert.ok(slot); + assert.strictEqual(slot.style.display, 'none'); + }); + + test('shows the picker when multiple session types are available', () => { + sessionTypes = [ + { id: 'copilotcli', label: 'Copilot CLI', icon: Codicon.copilot }, + { id: 'copilot-cloud-agent', label: 'Cloud', icon: Codicon.cloud }, + ]; + activeSession.set(createActiveSession('copilotcli'), undefined); + + const picker = disposables.add(instantiationService.createInstance(SessionTypePicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + assert.ok(slot); + assert.strictEqual(slot.style.display, ''); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts index 26718af64dfca..d3a69f34fd246 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -129,7 +129,7 @@ export class BranchPicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } dom.clearNode(this._triggerElement); @@ -145,8 +145,11 @@ export class BranchPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - this._slotElement?.classList.toggle('disabled', isLoading || isDisabled); - this._triggerElement.setAttribute('aria-disabled', String(isLoading || isDisabled)); - this._triggerElement.tabIndex = (isLoading || isDisabled) ? -1 : 0; + const visible = !(isLoading || isDisabled); + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 4e66caae48981..47901aa1bc425 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -29,7 +29,7 @@ import { ISession } from '../../sessions/common/sessionData.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; import { COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE } from '../../sessions/browser/sessionTypes.js'; -import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; +import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { IsolationPicker } from './isolationPicker.js'; import { BranchPicker } from './branchPicker.js'; import { ModePicker } from './modePicker.js'; @@ -56,6 +56,7 @@ registerAction2(class extends Action2 { group: 'navigation', order: 1, when: ContextKeyExpr.and( + IsNewChatSessionContext, IsActiveSessionCopilotChatCLI, ContextKeyExpr.equals('config.github.copilot.chat.cli.isolationOption.enabled', true), ), @@ -76,7 +77,10 @@ registerAction2(class extends Action2 { id: Menus.NewSessionRepositoryConfig, group: 'navigation', order: 2, - when: IsActiveSessionCopilotChatCLI, + when: ContextKeyExpr.and( + IsNewChatSessionContext, + IsActiveSessionCopilotChatCLI, + ), }], }); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts index d9e255932c9de..474ce3da58995 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts @@ -160,7 +160,7 @@ export class IsolationPicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -187,9 +187,11 @@ export class IsolationPicker extends Disposable { labelSpan.textContent = modeLabel; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - const isDisabled = !this._hasGitRepo; - this._slotElement?.classList.toggle('disabled', isDisabled); - this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); - this._triggerElement.tabIndex = isDisabled ? -1 : 0; + const visible = this._isolationOptionEnabled && this._hasGitRepo; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts index cb0e7cd087cc0..49cf7550029e6 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -223,7 +223,7 @@ export class ModePicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -239,6 +239,10 @@ export class ModePicker extends Disposable { dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); const modes = this._getAvailableModes(); - this._slotElement?.classList.toggle('disabled', modes.length <= 1); + const visible = modes.length > 1; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts index e29b55da61db6..82e8b93d376bb 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts @@ -198,7 +198,7 @@ export class CloudModelPicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -209,7 +209,11 @@ export class CloudModelPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - this._slotElement?.classList.toggle('disabled', this._models.length === 0); - this._triggerElement.setAttribute('aria-disabled', String(this._models.length === 0)); + const visible = this._models.length > 0; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index dfdb4aa6d4b9e..6818eef1bb8e2 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -24,6 +24,7 @@ import { IActionViewItemService } from '../../../../platform/actions/browser/act import { ISessionsManagementService } from './sessionsManagementService.js'; import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ChatSessionProviderIdContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; @@ -333,10 +334,17 @@ class SidebarToggleActionViewItem extends ActionViewItem { session.status.read(reader); session.isRead.read(reader); } + this.updateClass(); this._updateBadge(); })); } + protected override getClass(): string | undefined { + return this.layoutService.isVisible(Parts.SIDEBAR_PART) + ? ThemeIcon.asClassName(Codicon.layoutSidebarLeft) + : ThemeIcon.asClassName(Codicon.layoutSidebarLeftOff); + } + private _updateBadge(): void { if (!this._countBadge) { return; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a68b3c38e3dd1..60fd93fdc758c 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -45,7 +45,7 @@ import { ExtHostChatAgentsShape2, ExtHostContext, IChatSessionCustomizationItemD import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; -import { AICustomizationManagementSection } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; interface AgentData { @@ -634,17 +634,28 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA }, }; - // Convert metadata to a harness descriptor - const hiddenSections = metadata.unsupportedTypes?.map(type => { - switch (type) { - case 'agent': return AICustomizationManagementSection.Agents; - case 'skill': return AICustomizationManagementSection.Skills; - case 'instructions': return AICustomizationManagementSection.Instructions; - case 'prompt': return AICustomizationManagementSection.Prompts; - case 'hook': return AICustomizationManagementSection.Hooks; - default: return type; + // Convert supportedTypes whitelist to hiddenSections blacklist. + // Sections not in the supported list are hidden. When supportedTypes + // is omitted, all sections are shown. + const typeToSection: Record = { + 'agent': AICustomizationManagementSection.Agents, + 'skill': AICustomizationManagementSection.Skills, + 'instructions': AICustomizationManagementSection.Instructions, + 'prompt': AICustomizationManagementSection.Prompts, + 'hook': AICustomizationManagementSection.Hooks, + 'plugins': AICustomizationManagementSection.Plugins, + }; + let hiddenSections: string[] | undefined; + if (metadata.supportedTypes) { + const supportedSections = new Set(); + for (const t of metadata.supportedTypes) { + const section = typeToSection[t]; + if (section) { + supportedSections.add(section); + } } - }); + hiddenSections = Object.values(typeToSection).filter(section => !supportedSections.has(section)); + } const descriptor: IHarnessDescriptor = { id: chatSessionType, @@ -654,7 +665,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA getStorageSourceFilter: () => ({ // Extension-provided harnesses manage their own items via the provider, // so we show all sources for storage-filter-based flows. - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension], + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension, BUILTIN_STORAGE], }), itemProvider, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 187a771114d4d..351f1756db06b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1681,7 +1681,7 @@ export interface ISkillDto { export interface IChatSessionCustomizationProviderMetadataDto { readonly label: string; readonly iconId?: string; - readonly unsupportedTypes?: readonly string[]; + readonly supportedTypes?: readonly string[]; } export interface IChatSessionCustomizationItemDto { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index f6d282ea53cf8..8f5b62a9f12cd 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -671,7 +671,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const metadataDto: IChatSessionCustomizationProviderMetadataDto = { label: metadata.label, iconId: metadata.iconId, - unsupportedTypes: metadata.unsupportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), + supportedTypes: metadata.supportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), }; this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 4b2dd1db8d0a5..1906d97f8ad47 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3450,6 +3450,7 @@ export namespace ChatAgentRequest { parentRequestId: request.parentRequestId, hasHooksEnabled: request.hasHooksEnabled ?? false, hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined, + isSystemInitiated: request.isSystemInitiated, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 2e2646a0de13e..347d80f9be090 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3574,6 +3574,7 @@ export class ChatSessionCustomizationType { static readonly Instructions = new ChatSessionCustomizationType('instructions'); static readonly Prompt = new ChatSessionCustomizationType('prompt'); static readonly Hook = new ChatSessionCustomizationType('hook'); + static readonly Plugins = new ChatSessionCustomizationType('plugins'); constructor(public readonly id: string) { } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index f6568c66bf197..d5eeb3e8af342 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -7,7 +7,7 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; -import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { IInvokeFunctionResult, IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; @@ -42,6 +42,9 @@ export async function playwrightInvokeRaw( /** * Shared helper for running a Playwright function against a page and returning * a tool result. Handles success/error formatting. + * + * Calls {@link IPlaywrightService.invokeFunction} without a timeout so the + * action runs to completion — no deferred results are ever produced. */ export async function playwrightInvoke( playwrightService: IPlaywrightService, @@ -50,18 +53,44 @@ export async function playwrightInvoke( ...args: TArgs ): Promise { try { - const result = await playwrightService.invokeFunction(pageId, fn.toString(), ...args); - return { - content: [ - { kind: 'text', value: result.result ? JSON.stringify(result.result) : 'Script executed successfully' }, - { kind: 'text', value: result.summary } - ] - }; + const result = await playwrightService.invokeFunction(pageId, fn.toString(), args); + return invokeFunctionResultToToolResult(result); } catch (e) { return errorResult(e instanceof Error ? e.message : String(e)); } } +/** + * Convert an {@link IInvokeFunctionResult} to an {@link IToolResult}, + * including any {@link IInvokeFunctionResult.deferredResultId}. + */ +export function invokeFunctionResultToToolResult(result: IInvokeFunctionResult, code?: string): IToolResult { + const content: IToolResult['content'] = []; + if (result.result !== undefined) { + content.push({ kind: 'text', value: `Result: ${JSON.stringify(result.result)}` }); + } + if (result.error) { + content.push({ kind: 'text', value: result.error }); + } + if (result.deferredResultId) { + content.push({ kind: 'text', value: `[deferredResultId=${result.deferredResultId}] The code has not finished executing yet. Call run_playwright_code again with this deferredResultId and the same pageId (no code) to continue waiting.` }); + } + content.push({ kind: 'text', value: result.summary }); + return { + content, + ...(code ? { + toolResultDetails: { + input: code, + inputLanguage: 'javascript', + output: result.result || result.error + ? [{ type: 'embed' as const, isText: true, value: JSON.stringify(result.result ?? result.error, null, 2) }] + : [], + isError: !!result.error, + }, + } : {}), + }; +} + export function errorResult(message: string): IToolResult { return { content: [{ kind: 'text', value: message }], diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts index e07efe6265d93..dcea1e03405f6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -9,7 +9,7 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { errorResult } from './browserToolHelpers.js'; +import { errorResult, invokeFunctionResultToToolResult } from './browserToolHelpers.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const RunPlaywrightCodeToolData: IToolData = { @@ -29,16 +29,30 @@ export const RunPlaywrightCodeToolData: IToolData = { }, code: { type: 'string', - description: `The Playwright code to execute. The code must be concise, serve one clear purpose, and be self-contained. You **must not** directly access \`document\` or \`window\` using this tool. You must access it via the provided \`page\` object, e.g. "return page.evaluate(() => document.title)".` + description: `The Playwright code to execute. The code must be concise, serve one clear purpose, and be self-contained. You **must not** directly access \`document\` or \`window\` using this tool. You must access it via the provided \`page\` object, e.g. "return page.evaluate(() => document.title)". Omit this when resuming a deferred execution via deferredResultId.` + }, + deferredResultId: { + type: 'string', + description: `If a previous call returned a deferredResultId, pass it here to continue waiting for that execution to complete.` + }, + timeoutMs: { + type: 'number', + description: `Maximum time in milliseconds to wait for the code to complete. Defaults to 5000 (5 seconds).` }, }, - required: ['pageId', 'code'], + required: ['pageId'], + oneOf: [ + { required: ['code'] }, + { required: ['deferredResultId'] }, + ] }, }; interface IRunPlaywrightCodeToolParams { pageId: string; - code: string; + code?: string; + deferredResultId?: string; + timeoutMs?: number; } export class RunPlaywrightCodeTool implements IToolImpl { @@ -48,6 +62,14 @@ export class RunPlaywrightCodeTool implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const params = context.parameters as IRunPlaywrightCodeToolParams; + + if (params.deferredResultId) { + return { + invocationMessage: new MarkdownString(localize('browser.runCode.waitInvocation', "Waiting for Playwright code to complete...")), + pastTenseMessage: new MarkdownString(localize('browser.runCode.waitPast', "Waited for Playwright code")), + }; + } + const code = params.code ?? ''; return { invocationMessage: new MarkdownString(localize('browser.runCode.invocation', "Running Playwright code...")), @@ -68,41 +90,28 @@ export class RunPlaywrightCodeTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + // Resume waiting for a deferred execution + if (params.deferredResultId) { + try { + const result = await this.playwrightService.waitForDeferredResult(params.deferredResultId, params.timeoutMs ?? 5_000); + return invokeFunctionResultToToolResult(result); + } catch (e) { + return errorResult(e instanceof Error ? e.message : String(e)); + } + } + if (!params.code) { - return errorResult('The "code" parameter is required.'); + return errorResult('Either "code" or "deferredResultId" must be provided.'); } let result; try { - result = await this.playwrightService.invokeFunction(params.pageId, `async (page) => { ${params.code} }`); + result = await this.playwrightService.invokeFunction(params.pageId, `async (page) => { ${params.code} }`, undefined, params.timeoutMs ?? 5_000); } catch (e) { const message = e instanceof Error ? e.message : String(e); return errorResult(`Code execution failed: ${message}`); } - const json = JSON.stringify(result.result || null); - - let outputMessage; - if (result.result) { - outputMessage = new MarkdownString(); - outputMessage.appendMarkdown(localize('browser.runCode.outputLabel', 'Output:')); - outputMessage.appendText('\n'); - outputMessage.appendCodeblock('json', json); - } - - return { - content: [ - { kind: 'text', value: result.result ? json : 'Code executed successfully' }, - { kind: 'text', value: result.summary } - ], - toolResultDetails: { - input: params.code.trim(), - inputLanguage: 'javascript', - output: result.result - ? [{ type: 'embed', isText: true, value: JSON.stringify(result.result, null, 2) }] - : [], - isError: false, - }, - }; + return invokeFunctionResultToToolResult(result, params.code.trim()); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts index d604f351efb69..8df04dc28b3d6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts @@ -19,8 +19,7 @@ import { IEditorWorkerService } from '../../../../../../editor/common/services/e import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../../nls.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { ContentEncoding, IWriteFileParams } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { getToolFileEdits, ToolCallStatus, type IToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { FileEditKind, ToolCallStatus, type IToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { EditorActivation } from '../../../../../../platform/editor/common/editor.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -36,19 +35,11 @@ import { fileEditsToExternalEdits, type IToolCallFileEdit } from './stateToProgr // ---- Internal data model ---------------------------------------------------- -interface IAgentHostFileEdit { - readonly resource: URI; - readonly beforeContentUri: URI; - readonly afterContentUri: URI; - readonly undoStopId: string; - readonly diff?: { added?: number; removed?: number }; -} - interface IAgentHostCheckpoint { readonly requestId: string; /** Tool-call ID, or `undefined` for the sentinel checkpoint at request start. */ readonly undoStopId: string | undefined; - readonly edits: IAgentHostFileEdit[]; + readonly edits: IToolCallFileEdit[]; } // ---- Modified file entry ---------------------------------------------------- @@ -145,9 +136,6 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS private readonly _onDidDispose = this._register(new Emitter()); readonly onDidDispose: Event = this._onDidDispose.event; - private readonly _onDidRequestFileWrite = this._register(new Emitter()); - readonly onDidRequestFileWrite: Event = this._onDidRequestFileWrite.event; - private readonly _checkpoints: IAgentHostCheckpoint[] = []; private readonly _currentCheckpointIndex = observableValue(this, -1); private readonly _diffCache = new Map(); @@ -214,14 +202,15 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS } const authority = this._connectionAuthority; - const protocolEdits = getToolFileEdits(tc); - const edits: IAgentHostFileEdit[] = fileEdits.map((edit: IToolCallFileEdit, i: number) => ({ + const edits: IToolCallFileEdit[] = fileEdits.map((edit: IToolCallFileEdit) => ({ + kind: edit.kind, resource: toAgentHostUri(edit.resource, authority), - beforeContentUri: toAgentHostUri(edit.beforeContentUri, authority), - afterContentUri: toAgentHostUri(edit.afterContentUri, authority), + originalResource: edit.originalResource ? toAgentHostUri(edit.originalResource, authority) : undefined, + beforeContentUri: edit.beforeContentUri ? toAgentHostUri(edit.beforeContentUri, authority) : undefined, + afterContentUri: edit.afterContentUri ? toAgentHostUri(edit.afterContentUri, authority) : undefined, undoStopId: edit.undoStopId, - diff: protocolEdits[i]?.diff, + diff: edit.diff, })); const checkpoint: IAgentHostCheckpoint = { @@ -244,11 +233,24 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS // Build progress parts for the file edit pills in the chat response const progressParts: IChatProgress[] = []; for (const edit of edits) { - progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); - progressParts.push({ kind: 'codeblockUri', uri: edit.resource, isEdit: true, undoStopId: tc.toolCallId }); - progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: false, isExternalEdit: true }); - progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: true, isExternalEdit: true }); - progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + // Emit workspace file edit progress for creates, deletes, and renames + if (edit.kind === FileEditKind.Create || edit.kind === FileEditKind.Delete || edit.kind === FileEditKind.Rename) { + progressParts.push({ + kind: 'workspaceEdit', + edits: [{ + oldResource: edit.originalResource ?? (edit.kind === FileEditKind.Delete ? edit.resource : undefined), + newResource: edit.kind === FileEditKind.Delete ? undefined : edit.resource, + }], + }); + } + // Emit code-block UI for content edits (and renames/creates with content) + if (edit.afterContentUri) { + progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + progressParts.push({ kind: 'codeblockUri', uri: edit.resource, isEdit: true, undoStopId: tc.toolCallId }); + progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: false, isExternalEdit: true }); + progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: true, isExternalEdit: true }); + progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + } } return progressParts; } @@ -378,6 +380,9 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS } try { + if (!edit.afterContentUri) { + return VSBuffer.fromByteArray([]); + } const content = await this._fileService.readFile(edit.afterContentUri); return content.value; } catch (err) { @@ -697,7 +702,7 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS private _rebuildEntries(): void { const currentIdx = this._currentCheckpointIndex.get(); - const resourceMap = new Map(); + const resourceMap = new Map(); for (let i = 0; i <= currentIdx && i < this._checkpoints.length; i++) { const cp = this._checkpoints[i]; @@ -706,7 +711,9 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS const existing = resourceMap.get(key); if (existing) { // Update after-content to the latest, accumulate diff counts - existing.afterContentUri = edit.afterContentUri; + if (edit.afterContentUri) { + existing.afterContentUri = edit.afterContentUri; + } existing.requestId = cp.requestId; existing.added += edit.diff?.added ?? 0; existing.removed += edit.diff?.removed ?? 0; @@ -723,28 +730,90 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS } } - const entries = [...resourceMap.values()].map(v => - new AgentHostModifiedFileEntry(v.resource, v.beforeContentUri, v.requestId, v.added, v.removed) - ); + const entries = [...resourceMap.values()] + .filter(v => v.beforeContentUri && v.afterContentUri) + .map(v => + new AgentHostModifiedFileEntry(v.resource, v.beforeContentUri!, v.requestId, v.added, v.removed) + ); this._entriesObs.set(entries, undefined); } private async _writeCheckpointContent(checkpoint: IAgentHostCheckpoint, direction: 'before' | 'after'): Promise { - const writes = checkpoint.edits.map(async edit => { - const contentUri = direction === 'before' ? edit.beforeContentUri : edit.afterContentUri; + const ops = checkpoint.edits.map(async edit => { try { - const file = await this._fileService.readFile(contentUri); - this._onDidRequestFileWrite.fire({ - uri: edit.resource.toString(), - data: file.value.toString(), - encoding: ContentEncoding.Utf8, - }); + if (direction === 'before') { + // Undoing this edit + switch (edit.kind) { + case FileEditKind.Create: + // Undo create → delete the file + await this._fileService.del(edit.resource); + break; + case FileEditKind.Delete: + // Undo delete → recreate from before-snapshot + if (edit.beforeContentUri) { + const content = await this._fileService.readFile(edit.beforeContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + case FileEditKind.Rename: + // Undo rename → move back to original + if (edit.originalResource) { + await this._fileService.move(edit.resource, edit.originalResource, true); + } + // Also restore before-content if we have it + if (edit.beforeContentUri && edit.originalResource) { + const content = await this._fileService.readFile(edit.beforeContentUri); + await this._fileService.writeFile(edit.originalResource, content.value); + } + break; + case FileEditKind.Edit: + // Undo edit → write before-snapshot content + if (edit.beforeContentUri) { + const content = await this._fileService.readFile(edit.beforeContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + } + } else { + // Redoing this edit + switch (edit.kind) { + case FileEditKind.Create: + // Redo create → recreate from after-snapshot + if (edit.afterContentUri) { + const content = await this._fileService.readFile(edit.afterContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + case FileEditKind.Delete: + // Redo delete → delete the file again + await this._fileService.del(edit.resource); + break; + case FileEditKind.Rename: + // Redo rename → move from original to new + if (edit.originalResource) { + await this._fileService.move(edit.originalResource, edit.resource, true); + } + // Also apply after-content if we have it + if (edit.afterContentUri) { + const content = await this._fileService.readFile(edit.afterContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + case FileEditKind.Edit: + // Redo edit → write after-snapshot content + if (edit.afterContentUri) { + const content = await this._fileService.readFile(edit.afterContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + } + } } catch (err) { - this._logService.warn(`[AgentHostEditingSession] Failed to fetch content for ${direction}`, contentUri.toString(), err); + this._logService.warn(`[AgentHostEditingSession] Failed to ${direction === 'before' ? 'undo' : 'redo'} ${edit.kind} for ${edit.resource.toString()}`, err); } }); - await Promise.all(writes); + await Promise.all(ops); } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 3dc1a2ef8abab..9e2f12e055f1f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -181,8 +181,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private readonly _pendingMessageSubscriptions = this._register(new DisposableResourceMap()); /** Per-session subscription watching for server-initiated turns. */ private readonly _serverTurnWatchers = this._register(new DisposableResourceMap()); - /** Per-session writeFile listeners for agent host editing sessions. */ - private readonly _editingSessionListeners = this._register(new DisposableResourceMap()); /** Historical turns with file edits, pending hydration into the editing session. */ private readonly _pendingHistoryTurns = new ResourceMap(); /** Turn IDs dispatched by this client, used to distinguish server-originated turns. */ @@ -339,7 +337,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._sessionToBackend.delete(sessionResource); this._pendingMessageSubscriptions.deleteAndDispose(sessionResource); this._serverTurnWatchers.deleteAndDispose(sessionResource); - this._editingSessionListeners.deleteAndDispose(sessionResource); this._pendingHistoryTurns.delete(sessionResource); if (resolvedSession) { this._clientState.unsubscribe(resolvedSession.toString()); @@ -1255,24 +1252,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } - // Wire up the writeFile listener if not already done - if (!this._editingSessionListeners.has(sessionResource)) { - this._editingSessionListeners.set(sessionResource, editingSession.onDidRequestFileWrite(params => { - this._config.connection.writeFile(params).catch(err => { - this._logService.warn('[AgentHost] writeFile failed for undo/redo', err); - }); - })); - - // Hydrate from historical turns if this is the first time - // the editing session is accessed for this chat session. - const pendingTurns = this._pendingHistoryTurns.get(sessionResource); - if (pendingTurns) { - this._pendingHistoryTurns.delete(sessionResource); - for (const turn of pendingTurns) { - for (const rp of turn.responseParts) { - if (rp.kind === ResponsePartKind.ToolCall) { - editingSession.addToolCallEdits(turn.id, rp.toolCall); - } + // Hydrate from historical turns if this is the first time + // the editing session is accessed for this chat session. + const pendingTurns = this._pendingHistoryTurns.get(sessionResource); + if (pendingTurns) { + this._pendingHistoryTurns.delete(sessionResource); + for (const turn of pendingTurns) { + for (const rp of turn.responseParts) { + if (rp.kind === ResponsePartKind.ToolCall) { + editingSession.addToolCallEdits(turn.id, rp.toolCall); } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index c33486b56a67d..7aa234364b9ac 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -9,7 +9,7 @@ import { URI, UriComponents } from '../../../../../../base/common/uri.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; import { IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot, IWriteFileParams, IWriteFileResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -151,16 +151,28 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._inner.nextClientSeq(); } - async browseDirectory(uri: URI): Promise { - return this._logCall('browseDirectory', uri, () => this._inner.browseDirectory(uri)); + async resourceList(uri: URI): Promise { + return this._logCall('resourceList', uri, () => this._inner.resourceList(uri)); } - async fetchContent(uri: URI): Promise { - return this._logCall('fetchContent', uri, () => this._inner.fetchContent(uri)); + async resourceRead(uri: URI): Promise { + return this._logCall('resourceRead', uri, () => this._inner.resourceRead(uri)); } - async writeFile(params: IWriteFileParams): Promise { - return this._logCall('writeFile', params, () => this._inner.writeFile(params)); + async resourceWrite(params: IResourceWriteParams): Promise { + return this._logCall('resourceWrite', params, () => this._inner.resourceWrite(params)); + } + + async resourceCopy(params: IResourceCopyParams): Promise { + return this._logCall('resourceCopy', params, () => this._inner.resourceCopy(params)); + } + + async resourceDelete(params: IResourceDeleteParams): Promise { + return this._logCall('resourceDelete', params, () => this._inner.resourceDelete(params)); + } + + async resourceMove(params: IResourceMoveParams): Promise { + return this._logCall('resourceMove', params, () => this._inner.resourceMove(params)); } // ---- Public logging API for callers' catch blocks ----------------------- diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 69a2c1d7109cc..b4ed0a29e6729 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -5,12 +5,13 @@ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn, FileEditKind } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { type IToolConfirmationMessages, type IToolData, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { hasKey } from '../../../../../../base/common/types.js'; function getPtyTerminalData(meta: Record | undefined): { input?: string; output?: string } | undefined { @@ -183,18 +184,33 @@ function completedToolCallToEditParts(tc: ICompletedToolCall): IChatProgress[] { if (fileEdits.length === 0) { return []; } - const filePath = getFilePathFromToolInput(tc); - if (!filePath) { - return []; - } - const fileUri = URI.file(filePath); const parts: IChatProgress[] = []; - for (const _edit of fileEdits) { - parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); - parts.push({ kind: 'codeblockUri', uri: fileUri, isEdit: true, undoStopId: tc.toolCallId }); - parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: false, isExternalEdit: true }); - parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: true, isExternalEdit: true }); - parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + for (const edit of fileEdits) { + const fileUri = edit.after?.uri ? URI.parse(edit.after.uri) : edit.before?.uri ? URI.parse(edit.before.uri) : undefined; + if (!fileUri) { + continue; + } + // Emit workspace file edit progress for creates, deletes, and renames + const isCreate = !edit.before && !!edit.after; + const isDelete = !!edit.before && !edit.after; + const isRename = !!edit.before && !!edit.after && !isEqual(URI.parse(edit.before.uri), URI.parse(edit.after.uri)); + if (isCreate || isDelete || isRename) { + parts.push({ + kind: 'workspaceEdit', + edits: [{ + oldResource: edit.before?.uri ? URI.parse(edit.before.uri) : undefined, + newResource: edit.after?.uri ? URI.parse(edit.after.uri) : undefined, + }], + }); + } + // Emit code-block UI for content edits (and renames with content changes) + if (edit.after?.content) { + parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + parts.push({ kind: 'codeblockUri', uri: fileUri, isEdit: true, undoStopId: tc.toolCallId }); + parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: false, isExternalEdit: true }); + parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: true, isExternalEdit: true }); + parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + } } return parts; } @@ -283,14 +299,20 @@ export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocatio * that should be routed through the editing session's external edits pipeline. */ export interface IToolCallFileEdit { - /** The real file URI on the remote (e.g., `file:///path/to/file`). */ + /** The kind of file operation. */ + readonly kind: FileEditKind; + /** The primary file URI (after-URI for edits/creates/renames, before-URI for deletes). */ readonly resource: URI; - /** URI to read the before-snapshot content from. */ - readonly beforeContentUri: URI; - /** URI to read the after-content from (real file on remote via agenthost:// scheme). */ - readonly afterContentUri: URI; + /** For renames, the original file URI before the move. */ + readonly originalResource?: URI; + /** URI to read the before-snapshot content from. Absent for creates. */ + readonly beforeContentUri?: URI; + /** URI to read the after-content from. Absent for deletes. */ + readonly afterContentUri?: URI; /** Undo stop ID for grouping edits. */ readonly undoStopId: string; + /** Optional diff display metadata. */ + readonly diff?: { added?: number; removed?: number }; } /** @@ -349,31 +371,35 @@ export function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[ } const result: IToolCallFileEdit[] = []; for (const edit of edits) { - const filePath = getFilePathFromToolInput(tc); - if (filePath) { - result.push({ - resource: URI.file(filePath), - beforeContentUri: URI.parse(edit.beforeURI), - afterContentUri: URI.parse(edit.afterURI), - undoStopId: tc.toolCallId, - }); + const isCreate = !edit.before && !!edit.after; + const isDelete = !!edit.before && !edit.after; + const isRename = !!edit.before && !!edit.after && !isEqual(URI.parse(edit.before.uri), URI.parse(edit.after.uri)); + + let kind: FileEditKind; + if (isCreate) { + kind = FileEditKind.Create; + } else if (isDelete) { + kind = FileEditKind.Delete; + } else if (isRename) { + kind = FileEditKind.Rename; + } else { + kind = FileEditKind.Edit; } - } - return result; -} -/** - * Extracts the file path from a tool call's input parameters. - * Edit tools store the file path in JSON parameters as `path`. - */ -export function getFilePathFromToolInput(tc: IToolCallState): string | undefined { - if (tc.status !== ToolCallStatus.Completed || !tc.toolInput) { - return undefined; - } - try { - const params = JSON.parse(tc.toolInput); - return typeof params.path === 'string' ? params.path : undefined; - } catch { - return undefined; + const resource = edit.after?.uri ? URI.parse(edit.after.uri) : edit.before?.uri ? URI.parse(edit.before.uri) : undefined; + if (!resource) { + continue; + } + + result.push({ + kind, + resource, + originalResource: isRename ? URI.parse(edit.before!.uri) : undefined, + beforeContentUri: edit.before?.content.uri ? URI.parse(edit.before.content.uri) : undefined, + afterContentUri: edit.after?.content.uri ? URI.parse(edit.after.content.uri) : undefined, + undoStopId: tc.toolCallId, + diff: edit.diff, + }); } + return result; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 62b0b87c72ece..d255da3cfcd90 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -9,7 +9,7 @@ import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promp import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { AICustomizationManagementSection } from './aiCustomizationManagement.js'; -import { IExternalCustomizationItemProvider } from '../../common/customizationHarnessService.js'; +import { IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; /** * Maps section ID to prompt type. Duplicated from aiCustomizationListWidget @@ -35,7 +35,7 @@ function sectionToPromptType(section: AICustomizationManagementSection): Prompts * Snapshot of the list widget's internal state, passed in to avoid coupling. */ export interface IDebugWidgetState { - readonly allItems: readonly { readonly storage?: PromptsStorage }[]; + readonly allItems: readonly { readonly name?: string; readonly storage?: PromptsStorage; readonly groupKey?: string }[]; readonly displayEntries: readonly { type: string; label?: string; count?: number; collapsed?: boolean }[]; } @@ -48,8 +48,9 @@ export async function generateCustomizationDebugReport( promptsService: IPromptsService, workspaceService: IAICustomizationWorkspaceService, widgetState: IDebugWidgetState, - externalProvider?: IExternalCustomizationItemProvider, + activeDescriptor?: IHarnessDescriptor, ): Promise { + const externalProvider = activeDescriptor?.itemProvider; const promptType = sectionToPromptType(section); const filter = workspaceService.getStorageSourceFilter(promptType); const lines: string[] = []; @@ -59,6 +60,22 @@ export async function generateCustomizationDebugReport( lines.push(`Active root: ${workspaceService.getActiveProjectRoot()?.fsPath ?? '(none)'}`); lines.push(`Sections: [${workspaceService.managementSections.join(', ')}]`); lines.push(`Filter sources: [${filter.sources.join(', ')}]`); + + // Dump active harness descriptor + if (activeDescriptor) { + lines.push(''); + lines.push('--- Active Harness ---'); + lines.push(` id: ${activeDescriptor.id}`); + lines.push(` label: ${activeDescriptor.label}`); + lines.push(` hasItemProvider: ${!!activeDescriptor.itemProvider}`); + lines.push(` hasSyncProvider: ${!!activeDescriptor.syncProvider}`); + lines.push(` hiddenSections: ${activeDescriptor.hiddenSections ? `[${activeDescriptor.hiddenSections.join(', ')}]` : '(none)'}`); + lines.push(` workspaceSubpaths: ${activeDescriptor.workspaceSubpaths ? `[${activeDescriptor.workspaceSubpaths.join(', ')}]` : '(none)'}`); + lines.push(` hideGenerateButton: ${activeDescriptor.hideGenerateButton ?? false}`); + lines.push(` requiredAgentId: ${activeDescriptor.requiredAgentId ?? '(none)'}`); + lines.push(` instructionFileFilter: ${activeDescriptor.instructionFileFilter ? `[${activeDescriptor.instructionFileFilter.join(', ')}]` : '(none)'}`); + } + lines.push(''); if (filter.includedUserFileRoots) { lines.push(`Filter includedUserFileRoots:`); for (const r of filter.includedUserFileRoots) { @@ -109,6 +126,19 @@ async function appendExternalProviderData(lines: string[], provider: IExternalCu if (item.description) { lines.push(` desc: ${item.description}`); } + if (item.groupKey) { + lines.push(` groupKey: ${item.groupKey}`); + } + if (item.badge) { + lines.push(` badge: ${item.badge}`); + } + if (item.status) { + lines.push(` status: ${item.status}${item.statusMessage ? ` (${item.statusMessage})` : ''}`); + } + if (item.enabled === false) { + lines.push(` enabled: false`); + } + lines.push(` scheme: ${item.uri.scheme}`); } } @@ -209,6 +239,11 @@ function appendWidgetState(lines: string[], state: IDebugWidgetState): void { lines.push(` extension: ${state.allItems.filter(i => i.storage === PromptsStorage.extension).length}`); lines.push(` plugin: ${state.allItems.filter(i => i.storage === PromptsStorage.plugin).length}`); + // Show each item with its groupKey and storage + for (const item of state.allItems) { + lines.push(` - ${item.name} [storage=${item.storage ?? '?'}, groupKey=${item.groupKey ?? '(none)'}]`); + } + lines.push(` displayEntries (after filterItems): ${state.displayEntries.length}`); const fileEntries = state.displayEntries.filter(e => e.type === 'file-item'); lines.push(` file items shown: ${fileEntries.length}`); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 75cde4ff73ccd..4afa34724d807 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -8,6 +8,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { autorun } from '../../../../../base/common/observable.js'; @@ -1161,7 +1162,13 @@ export class AICustomizationListWidget extends Disposable { */ private async loadItems(): Promise { const section = this.currentSection; - const items = await this.fetchItemsForSection(section); + let items: IAICustomizationListItem[]; + try { + items = await this.fetchItemsForSection(section); + } catch (err) { + onUnexpectedError(err); + items = []; + } if (this.currentSection !== section) { return; // section changed while loading @@ -1226,25 +1233,38 @@ export class AICustomizationListWidget extends Disposable { /** * Fetches and filters items for a given section. - * Shared between `loadItems` (active section) and `computeItemCountForSection` (any section). + * Delegates to the provider path or core path based on the active harness. */ private async fetchItemsForSection(section: AICustomizationManagementSection): Promise { const promptType = sectionToPromptType(section); - - // When the active harness has an external item provider, delegate to it - // instead of querying promptsService and applying filters. - // When the harness also has a syncProvider, include local items with - // sync toggles alongside the remote items. const activeDescriptor = this.harnessService.getActiveDescriptor(); + if (activeDescriptor.itemProvider && promptType) { - const remoteItems = await this.fetchItemsFromProvider(activeDescriptor.itemProvider, promptType); - if (!activeDescriptor.syncProvider) { - return remoteItems; - } - const localItems = await this.fetchLocalSyncableItems(promptType, activeDescriptor.syncProvider); - return [...remoteItems, ...localItems]; + return this.fetchProviderItemsForSection(activeDescriptor, promptType); } + return this.fetchCoreItemsForSection(promptType); + } + + /** + * Fetches items from an external customization provider. + * When a syncProvider is present, blends remote items with local sync items. + */ + private async fetchProviderItemsForSection(descriptor: ReturnType, promptType: PromptsType): Promise { + const remoteItems = await this.fetchItemsFromProvider(descriptor.itemProvider!, promptType); + if (!descriptor.syncProvider) { + return remoteItems; + } + const localItems = await this.fetchLocalSyncableItems(promptType, descriptor.syncProvider); + return [...remoteItems, ...localItems]; + } + + /** + * Fetches items from the core promptsService with full filtering pipeline. + * This is the legacy path used when no external provider is active. + * TODO: Remove when provider API is the sole code path. + */ + private async fetchCoreItemsForSection(promptType: PromptsType): Promise { const items: IAICustomizationListItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); @@ -1612,22 +1632,69 @@ export class AICustomizationListWidget extends Disposable { return []; } + const workspaceFolders = this.workspaceContextService.getWorkspace().folders; + + // Build a URI→description lookup from promptsService for items the provider + // doesn't supply descriptions for (e.g. skills and instructions from ChatResource). + const descriptionsByUri = new ResourceMap(); + if (promptType === PromptsType.skill) { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + for (const s of skills ?? []) { + if (s.description) { + descriptionsByUri.set(s.uri, s.description); + } + } + } + return allItems .filter(item => item.type === promptType) - .map((item: IExternalCustomizationItem) => ({ - id: item.uri.toString(), - uri: item.uri, - name: item.name, - filename: basename(item.uri), - description: item.description, - promptType, - disabled: item.enabled === false, - status: item.status, - statusMessage: item.statusMessage, - })) + .map((item: IExternalCustomizationItem) => { + const { storage, groupKey } = item.groupKey + ? { storage: undefined, groupKey: item.groupKey } + : this._inferStorageAndGroup(item.uri, workspaceFolders); + return { + id: item.uri.toString(), + uri: item.uri, + name: item.name, + filename: basename(item.uri), + description: item.description ?? descriptionsByUri.get(item.uri), + promptType, + disabled: item.enabled === false, + status: item.status, + statusMessage: item.statusMessage, + groupKey, + badge: item.badge, + badgeTooltip: item.badgeTooltip, + storage, + }; + }) .sort((a, b) => a.name.localeCompare(b.name)); } + /** + * Infers storage and groupKey from a URI for auto-grouping. + * + * - `file:` URIs under a workspace folder → storage `local` (Workspace group) + * - `file:` URIs elsewhere (e.g. `~/.copilot/`) → storage `user` (User group) + * - Non-file schemes (synthetic URIs, vscode-userdata:, etc.) → groupKey `builtin` (Built-in group) + */ + private _inferStorageAndGroup(uri: URI, workspaceFolders: readonly { uri: URI }[]): { storage?: PromptsStorage; groupKey?: string } { + // Non-file schemes are synthetic/built-in (includes vscode-userdata: for extension-contributed items) + if (uri.scheme !== Schemas.file) { + return { groupKey: BUILTIN_STORAGE }; + } + + // file: URI under a workspace folder = workspace (local) + for (const folder of workspaceFolders) { + if (isEqualOrParent(uri, folder.uri)) { + return { storage: PromptsStorage.local }; + } + } + + // file: URI elsewhere = user directory + return { storage: PromptsStorage.user }; + } + /** * Fetches local customization items and marks them as syncable, using * the sync provider to determine their current selection state. @@ -1678,120 +1745,46 @@ export class AICustomizationListWidget extends Disposable { /** * Filters items based on the current search query and builds grouped display entries. */ - private filterItems(): number { - let matchedItems: IAICustomizationListItem[]; - + /** + * Applies the search query to items, returning matched items with highlight info. + */ + private applySearchFilter(items: IAICustomizationListItem[]): IAICustomizationListItem[] { if (!this.searchQuery.trim()) { - matchedItems = this.allItems.map(item => ({ ...item, nameMatches: undefined, descriptionMatches: undefined })); - } else { - const query = this.searchQuery.toLowerCase(); - matchedItems = []; - - for (const item of this.allItems) { - // Compute matches against the formatted display name so highlight positions - // are correct even after .md stripping and title-casing. - const displayName = item.displayName ?? formatDisplayName(item.name); - const nameMatches = matchesContiguousSubString(query, displayName); - const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; - const filenameMatches = matchesContiguousSubString(query, item.filename); - const badgeMatches = item.badge ? matchesContiguousSubString(query, item.badge) : null; - - if (nameMatches || descriptionMatches || filenameMatches || badgeMatches) { - matchedItems.push({ - ...item, - nameMatches: nameMatches || undefined, - descriptionMatches: descriptionMatches || undefined, - }); - } - } + return items.map(item => ({ ...item, nameMatches: undefined, descriptionMatches: undefined })); } - // When items come from an external provider, skip storage-based grouping - // and render a flat list. When a syncProvider is also present, show - // remote items first, then local items below with sync checkboxes. - // Synced local items sort to the top of the local group; unsynced - // items appear greyed out below them. - const activeDescriptor = this.harnessService.getActiveDescriptor(); - if (activeDescriptor.itemProvider) { - if (activeDescriptor.syncProvider) { - const remoteItems = matchedItems.filter(i => !i.syncable); - const localItems = matchedItems.filter(i => i.syncable); - const entries: IListEntry[] = []; + const query = this.searchQuery.toLowerCase(); + const matched: IAICustomizationListItem[] = []; - // Remote items first (flat, no group header) - for (const item of remoteItems.sort((a, b) => a.name.localeCompare(b.name))) { - entries.push({ type: 'file-item' as const, item }); - } + for (const item of items) { + const displayName = item.displayName ?? formatDisplayName(item.name); + const nameMatches = matchesContiguousSubString(query, displayName); + const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; + const filenameMatches = matchesContiguousSubString(query, item.filename); + const badgeMatches = item.badge ? matchesContiguousSubString(query, item.badge) : null; - // Local items below with a group header, synced items first - if (localItems.length > 0) { - const syncedCount = localItems.filter(i => i.synced).length; - entries.push({ - type: 'group-header' as const, - id: 'group-sync-local', - groupKey: 'sync-local', - label: localize('localGroup', "Local"), - icon: Codicon.folder, - count: syncedCount, - isFirst: remoteItems.length === 0, - description: localize('localGroupDescription', "Local customizations available to sync to the remote agent."), - collapsed: false, - }); - // Sort: synced items first, then alphabetical within each group - const sorted = localItems.sort((a, b) => { - if (a.synced !== b.synced) { - return a.synced ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - for (const item of sorted) { - entries.push({ type: 'file-item' as const, item: item.synced ? item : { ...item, disabled: true } }); - } - } - - this.displayEntries = entries; - } else { - matchedItems.sort((a, b) => a.name.localeCompare(b.name)); - this.displayEntries = matchedItems.map(item => ({ type: 'file-item' as const, item })); + if (nameMatches || descriptionMatches || filenameMatches || badgeMatches) { + matched.push({ + ...item, + nameMatches: nameMatches || undefined, + descriptionMatches: descriptionMatches || undefined, + }); } - this.list.splice(0, this.list.length, this.displayEntries); - this.updateEmptyState(); - return matchedItems.length; } - // Group items by storage - const promptType = sectionToPromptType(this.currentSection); - const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); - const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = - this.currentSection === AICustomizationManagementSection.Instructions - ? [ - { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, - { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, - { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, - ] - : [ - { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, - { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, - ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); - - for (const item of matchedItems) { - const key = item.groupKey ?? item.storage ?? PromptsStorage.local; - const group = groups.find(g => g.groupKey === key); - if (group) { - group.items.push(item); - } - } + return matched; + } + /** + * Builds grouped display entries from items assigned to groups. + * Empty groups are omitted. Collapsed groups show only their header. + */ + private buildGroupedEntries(groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[]): void { // Sort items within each group for (const group of groups) { group.items.sort((a, b) => a.name.localeCompare(b.name)); } - // Build display entries: group header + items (hidden if collapsed) this.displayEntries = []; let isFirstGroup = true; for (const group of groups) { @@ -1820,9 +1813,131 @@ export class AICustomizationListWidget extends Disposable { } } } + } + /** + * Commits the current displayEntries to the list and updates empty state. + */ + private commitDisplayEntries(): void { this.list.splice(0, this.list.length, this.displayEntries); this.updateEmptyState(); + } + + /** + * Filters and groups items from an external provider. + * When a syncProvider is present, shows remote items + local sync items. + * Otherwise, groups items by inferred storage/groupKey. + */ + private filterItemsForProvider(matchedItems: IAICustomizationListItem[]): void { + const activeDescriptor = this.harnessService.getActiveDescriptor(); + + if (activeDescriptor.syncProvider) { + // Sync layout: remote items flat, then local items with sync checkboxes + const remoteItems = matchedItems.filter(i => !i.syncable); + const localItems = matchedItems.filter(i => i.syncable); + const entries: IListEntry[] = []; + + for (const item of remoteItems.sort((a, b) => a.name.localeCompare(b.name))) { + entries.push({ type: 'file-item' as const, item }); + } + + if (localItems.length > 0) { + const syncedCount = localItems.filter(i => i.synced).length; + entries.push({ + type: 'group-header' as const, + id: 'group-sync-local', + groupKey: 'sync-local', + label: localize('localGroup', "Local"), + icon: Codicon.folder, + count: syncedCount, + isFirst: remoteItems.length === 0, + description: localize('localGroupDescription', "Local customizations available to sync to the remote agent."), + collapsed: false, + }); + const sorted = localItems.sort((a, b) => { + if (a.synced !== b.synced) { + return a.synced ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + for (const item of sorted) { + entries.push({ type: 'file-item' as const, item: item.synced ? item : { ...item, disabled: true } }); + } + } + + this.displayEntries = entries; + } else { + // Standard provider layout: group by inferred storage/groupKey + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, + ]; + + for (const item of matchedItems) { + const key = item.groupKey ?? item.storage ?? PromptsStorage.local; + const group = groups.find(g => g.groupKey === key); + if (group) { + group.items.push(item); + } + } + + this.buildGroupedEntries(groups); + } + + this.commitDisplayEntries(); + } + + /** + * Filters and groups items from the core promptsService (static harness path). + * Instructions use semantic categories; other sections use storage-based groups. + */ + private filterItemsForCore(matchedItems: IAICustomizationListItem[]): void { + const promptType = sectionToPromptType(this.currentSection); + const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); + + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = + this.currentSection === AICustomizationManagementSection.Instructions + ? [ + { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, + { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, + { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, + ] + : [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, + { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, + ].filter(g => g.groupKey === BUILTIN_STORAGE || g.groupKey === 'agents' || visibleSources.has(g.groupKey as PromptsStorage)); + + for (const item of matchedItems) { + const key = item.groupKey ?? item.storage ?? PromptsStorage.local; + const group = groups.find(g => g.groupKey === key); + if (group) { + group.items.push(item); + } + } + + this.buildGroupedEntries(groups); + this.commitDisplayEntries(); + } + + /** + * Filters items based on the current search query and builds grouped display entries. + */ + private filterItems(): number { + const matchedItems = this.applySearchFilter(this.allItems); + const activeDescriptor = this.harnessService.getActiveDescriptor(); + + if (activeDescriptor.itemProvider) { + this.filterItemsForProvider(matchedItems); + } else { + this.filterItemsForCore(matchedItems); + } + return matchedItems.length; } @@ -2004,7 +2119,7 @@ export class AICustomizationListWidget extends Disposable { this.promptsService, this.workspaceService, { allItems: this.allItems, displayEntries: this.displayEntries }, - activeDescriptor.itemProvider, + activeDescriptor, ); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index b5fb824333936..fda68173118d1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -12,6 +12,7 @@ import { basename, dirname, isEqualOrParent } from '../../../../../base/common/r import { URI } from '../../../../../base/common/uri.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -631,6 +632,35 @@ class AICustomizationManagementActionsContribution extends Disposable implements } })); + // Generate Debug Report + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: AICustomizationManagementCommands.GenerateDebugReport, + title: localize2('generateDebugReport', "Generate Customization Debug Report"), + category: Categories.Developer, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + // Open the customizations editor if not already open + const input = AICustomizationManagementEditorInput.getOrCreate(); + const pane = await editorService.openEditor(input, { pinned: true }); + if (!(pane instanceof AICustomizationManagementEditor)) { + return; + } + const report = await pane.generateDebugReport(); + await editorService.openEditor({ + resource: undefined, + contents: report, + languageId: 'plaintext', + }); + } + })); + } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index b7edd605f2eba..bf4e3a9e74ce5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -32,6 +32,7 @@ export const AICustomizationManagementCommands = { CreateNewSkill: 'aiCustomization.createNewSkill', CreateNewInstructions: 'aiCustomization.createNewInstructions', CreateNewPrompt: 'aiCustomization.createNewPrompt', + GenerateDebugReport: 'aiCustomization.generateDebugReport', } as const; /** diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 690322fdd1170..b93715cb9e4dc 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -759,6 +759,11 @@ configurationRegistry.registerConfiguration({ } } }, + [ChatConfiguration.DefaultNewSessionMode]: { + type: 'string', + description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), + default: '', + }, [AgentHostEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), @@ -1606,6 +1611,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr this.newChatButtonExperimentIcon = ChatContextKeys.newChatButtonExperimentIcon.bindTo(this.contextKeyService); this.registerMaxRequestsSetting(); this.registerNewChatButtonIcon(); + this.registerDefaultModeSetting(); } @@ -1646,6 +1652,24 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr } }); } + + private registerDefaultModeSetting(): void { + this.experimentService.getTreatment('chatDefaultNewSessionMode').then(value => { + const node: IConfigurationNode = { + id: 'chatSidebar', + title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), + type: 'object', + properties: { + [ChatConfiguration.DefaultNewSessionMode]: { + type: 'string', + description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), + default: typeof value === 'string' ? value : '', + } + } + }; + configurationRegistry.updateConfigurations({ add: [node], remove: [] }); + }); + } } class ChatForegroundSessionCountContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 0bd1a6f70040e..5efce6f271d89 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -83,7 +83,7 @@ import { ChatMarkdownContentPart, codeblockHasClosingBackticks } from './chatCon import { ChatMcpServersInteractionContentPart } from './chatContentParts/chatMcpServersInteractionContentPart.js'; import { ChatDisabledClaudeHooksContentPart } from './chatContentParts/chatDisabledClaudeHooksContentPart.js'; import { ChatMultiDiffContentPart } from './chatContentParts/chatMultiDiffContentPart.js'; -import { ChatProgressContentPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; +import { ChatProgressContentPart, ChatProgressSubPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; import { ChatPullRequestContentPart } from './chatContentParts/chatPullRequestContentPart.js'; import { ChatQuotaExceededPart } from './chatContentParts/chatQuotaExceededPart.js'; import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, CollapsibleListPool } from './chatContentParts/chatReferencesContentPart.js'; @@ -749,9 +749,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.editRequests') === 'hover'); templateData.requestHover.classList.toggle('checkpoints-enabled', checkpointEnabled); templateData.elementDisposables.add(dom.addStandardDisposableListener(templateData.rowContainer, dom.EventType.CLICK, (e) => { @@ -832,7 +834,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.computeVisibleOptionGroups(); + this.getVisibleOptionGroupsModeAndUpdateContextKeys(this.getCurrentSessionResource()); this.agentSessionTypeKey.set(newSessionType); this.chatSessionSupportsDelegationKey.set(this.chatSessionsService.supportsDelegationForSessionType(newSessionType)); this.updateWidgetLockStateFromSessionType(newSessionType); @@ -829,20 +827,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._lastSessionPickerAction = action; this._lastSessionPickerOptions = pickerOptions; - const result = this.computeVisibleOptionGroups(); - if (!result) { + const sessionResource = this.getCurrentSessionResource(); + const visibleOptionGroups = this.getVisibleOptionGroupsModeAndUpdateContextKeys(sessionResource); + if (!visibleOptionGroups.length) { + return []; + } + + const effectiveSessionType = this.getEffectiveSessionType(sessionResource); + if (!effectiveSessionType) { return []; } - const { visibleGroupIds, optionGroups, effectiveSessionType } = result; this.chatSessionPickerWidgets.clearAndDisposeAll(); const widgets: ChatSessionPickerActionItem[] = []; - for (const optionGroup of optionGroups) { - if (!visibleGroupIds.has(optionGroup.id)) { - continue; - } - + for (const optionGroup of visibleOptionGroups) { const initialItem = this.getCurrentOptionForGroup(optionGroup.id); const initialState = { group: optionGroup, item: initialItem }; @@ -896,7 +895,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.selectedToolsModel.resetSessionEnablementState(); this._chatSessionIsEmpty = chatSessionIsEmpty; - // TODO@roblourens This is for an experiment which will be obsolete in a month or two and can then be removed. if (chatSessionIsEmpty) { this._setEmptyModelState(); } @@ -913,29 +911,32 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _setEmptyModelState() { - const storageKey = this.getDefaultModeExperimentStorageKey(); - const hasSetDefaultMode = this.storageService.getBoolean(storageKey, StorageScope.WORKSPACE, false); - if (!hasSetDefaultMode) { - const isAnonymous = this.entitlementService.anonymous; - this.experimentService.getTreatment('chat.defaultMode') - .then((defaultModeTreatment => { - if (isAnonymous) { - // be deterministic for anonymous users - // to support agentic flows with default - // model. - defaultModeTreatment = ChatModeKind.Agent; - } + if (this.entitlementService.anonymous) { + // Be deterministic for anonymous users to support + // agentic flows with default model. + this.setChatMode(ChatModeKind.Agent, false); + this.checkModelSupported(); + return; + } - if (typeof defaultModeTreatment === 'string') { - this.storageService.store(storageKey, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); - const defaultMode = validateChatMode(defaultModeTreatment); - if (defaultMode) { - this.logService.trace(`Applying default mode from experiment: ${defaultMode}`); - this.setChatMode(defaultMode, false); - this.checkModelSupported(); - } - } - })); + const rawDefaultMode = this.configurationService.getValue(ChatConfiguration.DefaultNewSessionMode); + if (typeof rawDefaultMode === 'string') { + const defaultMode = rawDefaultMode.trim(); + if (defaultMode) { + // Custom modes are loaded asynchronously, so they may not be available yet + // at session initialization time. Built-in modes (ask, edit, agent) are always available. + const defaultModeLower = defaultMode.toLowerCase(); + const resolved = this.chatModeService.findModeById(defaultMode) + ?? this.chatModeService.findModeByName(defaultMode) + ?? this.chatModeService.getModes().custom.find(m => m.name.get().toLowerCase() === defaultModeLower); + if (resolved) { + this.logService.trace(`[ChatInputPart] Applying default mode from setting: ${defaultMode} -> ${resolved.id}`); + this.setChatMode(resolved.id, false); + this.checkModelSupported(); + } else { + this.logService.trace(`[ChatInputPart] Default mode '${defaultMode}' not found in available modes`); + } + } } } @@ -1285,11 +1286,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - private getDefaultModeExperimentStorageKey(): string { - const tag = this.options.widgetViewKindTag; - return `chat.${tag}.hasSetDefaultModeByExperiment`; - } - logInputHistory(): void { const historyStr = this.history.values.map(entry => JSON.stringify(entry)).join('\n'); this.logService.info(`[${this.location}] Chat input history:`, historyStr); @@ -1573,24 +1569,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * * This method also updates the `chatSessionHasOptions` context key, which controls * whether the picker action is shown in the toolbar via its `when` clause. - * - * @returns The result containing visible group IDs and related context, or undefined - * if there are no visible option groups */ - private computeVisibleOptionGroups(): { - visibleGroupIds: Set; - optionGroups: IChatSessionProviderOptionGroup[]; - sessionResource: URI | undefined; - effectiveSessionType: string; - } | undefined { - const setNoOptions = () => { - this.chatSessionHasOptions.set(false); - this.chatSessionOptionsValid.set(true); - }; - - const sessionResource = this._widget?.viewModel?.model.sessionResource; - - // Check if this session type has a customAgentTarget + private getVisibleOptionGroupsModeAndUpdateContextKeys(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { const customAgentTarget = sessionResource && this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)); this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); @@ -1598,29 +1578,63 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const requiresCustomModels = sessionResource && this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(sessionResource)); this.chatSessionHasTargetedModels.set(!!requiresCustomModels); + const visibleOptionGroups = this.getVisibleOptionGroups(sessionResource); + if (!visibleOptionGroups.length) { + this.chatSessionHasOptions.set(false); + this.chatSessionOptionsValid.set(true); + return []; + } + + const allOptionsValid = sessionResource ? this.areAllOptionsValid(sessionResource, visibleOptionGroups) : true; + + this.chatSessionHasOptions.set(true); + this.chatSessionOptionsValid.set(allOptionsValid); + + return visibleOptionGroups; + } + + private getCurrentSessionResource() { + return this._widget?.viewModel?.model.sessionResource; + } + + private areAllOptionsValid(sessionResource: URI, visibleOptionGroups: readonly IChatSessionProviderOptionGroup[]): boolean { + for (const optionGroup of visibleOptionGroups) { + const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); + if (currentOption) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + // TODO: @osortega @joshspicer should we add a `placeHolder` item to option groups to straighten this check? + if (!optionGroup.items.some(item => item.id === currentOptionId) && typeof currentOption === 'string') { + return false; + } + } + } + return true; + } - // Step 1: Determine the session type + private getAllOptionsGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { // - Panel/Editor: Use actual session's type (ctx available) // - Welcome view: Use delegate's type (ctx may not exist yet) const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); const effectiveSessionType = delegateSessionType ?? (sessionResource ? getChatSessionType(sessionResource) : undefined); - if (!effectiveSessionType) { - setNoOptions(); - return undefined; + return []; } // Step 2: Get option groups for this session type - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); - if (!optionGroups || optionGroups.length === 0) { - setNoOptions(); - return undefined; + const allOptionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); + return allOptionGroups ?? []; + } + + private getVisibleOptionGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { + const allOptionGroups = this.getAllOptionsGroups(sessionResource); + if (!allOptionGroups.length) { + return []; } // Update context keys with current option values before evaluating `when` clauses. // This ensures interdependent `when` expressions work correctly. if (sessionResource) { - for (const optionGroup of optionGroups) { + for (const optionGroup of allOptionGroups) { const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); if (currentOption) { const optionId = typeof currentOption === 'string' ? currentOption : currentOption.id; @@ -1629,9 +1643,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - // Step 3: Filter to visible groups (has items AND passes `when` clause AND session has option configured) - const visibleGroupIds = new Set(); - for (const optionGroup of optionGroups) { + // Filter to visible groups (has items AND passes `when` clause AND session has option configured) + const visibleGroups = new Map(); + for (const optionGroup of allOptionGroups) { const hasItems = optionGroup.items.length > 0 || (optionGroup.commands || []).length > 0; const passesWhenClause = this.evaluateOptionGroupVisibility(optionGroup); @@ -1640,36 +1654,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionHasOption = !sessionResource || this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id) !== undefined; if (hasItems && passesWhenClause && sessionHasOption) { - visibleGroupIds.add(optionGroup.id); - } - } - - if (visibleGroupIds.size === 0) { - setNoOptions(); - return undefined; - } - - // Validate selected options exist in their respective groups - let allOptionsValid = true; - if (sessionResource) { - for (const groupId of visibleGroupIds) { - const optionGroup = optionGroups.find(g => g.id === groupId); - const currentOption = this.chatSessionsService.getSessionOption(sessionResource, groupId); - if (optionGroup && currentOption) { - const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; - // TODO: @osortega @joshspicer should we add a `placeHolder` item to option groups to straighten this check? - if (!optionGroup.items.some(item => item.id === currentOptionId) && typeof currentOption === 'string') { - allOptionsValid = false; - break; - } - } + visibleGroups.set(optionGroup.id, optionGroup); } } - this.chatSessionHasOptions.set(true); - this.chatSessionOptionsValid.set(allOptionsValid); - - return { visibleGroupIds, optionGroups, sessionResource, effectiveSessionType }; + return Array.from(visibleGroups.values()); } /** @@ -1678,21 +1667,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ private refreshChatSessionPickers(): void { // Use the shared helper to compute visibility and update context keys - const result = this.computeVisibleOptionGroups(); - - if (!result) { + const sessionResource = this.getCurrentSessionResource(); + const allOptionsGroups = this.getAllOptionsGroups(sessionResource); + const visibleOptionGroups = this.getVisibleOptionGroupsModeAndUpdateContextKeys(sessionResource); + if (!allOptionsGroups.length || !visibleOptionGroups.length) { // No visible options - helper already updated context keys this.hideAllSessionPickerWidgets(); return; } - const { visibleGroupIds, optionGroups, sessionResource } = result; - // Check if widgets need recreation (different set of visible groups) const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = - currentWidgetGroupIds.size !== visibleGroupIds.size || - !Array.from(visibleGroupIds).every(id => currentWidgetGroupIds.has(id)); + currentWidgetGroupIds.size !== visibleOptionGroups.length || + !visibleOptionGroups.every(group => currentWidgetGroupIds.has(group.id)); if (needsRecreation && this._lastSessionPickerAction && this.chatSessionPickerContainer) { const widgets = this.createChatSessionPickerWidgets(this._lastSessionPickerAction, this._lastSessionPickerOptions); @@ -1714,7 +1702,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge for (const [optionGroupId] of this.chatSessionPickerWidgets) { const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroupId); if (currentOption) { - const optionGroup = optionGroups.find(g => g.id === optionGroupId); + const optionGroup = allOptionsGroups.find(g => g.id === optionGroupId); if (optionGroup) { const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; const item = optionGroup.items.find((m: IChatSessionProviderOptionItem) => m.id === currentOptionId); @@ -1753,8 +1741,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - const effectiveSessionType = this.getEffectiveSessionType(sessionResource, this.options.sessionTypePickerDelegate); - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); + const effectiveSessionType = this.getEffectiveSessionType(sessionResource); + const optionGroups = effectiveSessionType ? this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType) : undefined; const optionGroup = optionGroups?.find(g => g.id === optionGroupId); if (!optionGroup || optionGroup.items.length === 0) { return; @@ -1788,8 +1776,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return false; } - private getEffectiveSessionType(sessionResource: URI | undefined, delegate: ISessionTypePickerDelegate | undefined): string { - return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() || (sessionResource && getChatSessionType(sessionResource)) || ''; + private getEffectiveSessionType(sessionResource: URI | undefined): string | undefined { + return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() ?? (sessionResource ? getChatSessionType(sessionResource) : undefined); } /** @@ -1888,7 +1876,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this._widget = widget; - this.computeVisibleOptionGroups(); + this.getVisibleOptionGroupsModeAndUpdateContextKeys(this.getCurrentSessionResource()); // Initialize lock state when rendering with a pre-selected session provider (e.g., welcome view restore) const delegate = this.options.sessionTypePickerDelegate; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 651c9f4816839..b4983f5f69f6c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3067,6 +3067,20 @@ have to be updated for changes to the rules above, or to support more deeply nes } } + /* System-initiated requests render as compact progress-style messages, not bubbles */ + .interactive-item-container.interactive-request.system-initiated-request { + align-items: flex-start; + } + + .interactive-item-container.interactive-request.system-initiated-request .value .rendered-markdown { + background-color: transparent; + border-radius: 0; + padding: 0; + max-width: 100%; + margin-left: 0; + width: auto; + } + .request-hover { position: absolute; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 90a0ecd4eea57..3e0f1eec780e9 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1407,6 +1407,20 @@ export interface IChatSendRequestOptions { */ pauseQueue?: boolean; + /** + * When true, the request is rendered as a compact tool-progress-style line + * instead of a full user message bubble. Used for system-initiated notifications + * such as terminal command completion. + */ + isSystemInitiated?: boolean; + + /** + * Display label for system-initiated requests. When set, the request row renders + * this label as a compact progress-style message instead of the full request text. + */ + systemInitiatedLabel?: string; + + } export type IChatModelReference = IReference; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 6816cabd79d33..be9efdf281716 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -860,6 +860,8 @@ export class ChatService extends Disposable implements IChatService { attachedContext: options.attachedContext, modelId: options.userSelectedModelId, userSelectedTools: options.userSelectedTools?.get(), + isSystemInitiated: options.isSystemInitiated, + systemInitiatedLabel: options.systemInitiatedLabel, }); const deferred = new DeferredPromise(); @@ -1162,7 +1164,7 @@ export class ChatService extends Disposable implements IChatService { if (agentPart || (defaultAgent && !commandPart)) { const prepareChatAgentRequest = (agent: IChatAgentData, command?: IChatAgentCommand, enableCommandDetection?: boolean, chatRequest?: ChatRequestModel, isParticipantDetected?: boolean): IChatAgentRequest => { const initVariableData: IChatRequestVariableData = { variables: [] }; - request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, agent, command, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get()); + request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, agent, command, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), undefined, options?.isSystemInitiated, options?.systemInitiatedLabel); let variableData: IChatRequestVariableData; let message: string; @@ -1206,6 +1208,7 @@ export class ChatService extends Disposable implements IChatService { editedFileEvents: request.editedFileEvents, hooks: collectedHooks, hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), + isSystemInitiated: options?.isSystemInitiated, }; let isInitialTools = true; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 0f8c8034f1b12..932db37d961af 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -66,6 +66,7 @@ export enum ChatConfiguration { ArtifactsRulesByMimeType = 'chat.artifacts.rules.byMimeType', ArtifactsRulesByFilePath = 'chat.artifacts.rules.byFilePath', CustomizationsProviderApi = 'chat.customizations.providerApi.enabled', + DefaultNewSessionMode = 'chat.newSession.defaultMode', } /** @@ -77,17 +78,6 @@ export enum ChatModeKind { Agent = 'agent' } -export function validateChatMode(mode: unknown): ChatModeKind | undefined { - switch (mode) { - case ChatModeKind.Ask: - case ChatModeKind.Edit: - case ChatModeKind.Agent: - return mode as ChatModeKind; - default: - return undefined; - } -} - /** * The permission level controlling tool auto-approval behavior. */ @@ -108,10 +98,6 @@ export function isAutoApproveLevel(level: ChatPermissionLevel | undefined): bool return level === ChatPermissionLevel.AutoApprove || level === ChatPermissionLevel.Autopilot; } -export function isChatMode(mode: unknown): mode is ChatModeKind { - return !!validateChatMode(mode); -} - // Thinking display modes for pinned content export enum ThinkingDisplayMode { Collapsed = 'collapsed', diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 425b1e0fffc55..f368bbfd5de32 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -126,6 +126,8 @@ export interface IChatRequestModel { setShouldBeBlocked(value: boolean): void; readonly modelId?: string; readonly userSelectedTools?: UserSelectedTools; + readonly isSystemInitiated?: boolean; + readonly systemInitiatedLabel?: string; } export interface ICodeBlockInfo { @@ -342,6 +344,8 @@ export interface IChatRequestModelParameters { restoredId?: string; editedFileEvents?: IChatAgentEditedFileEvent[]; userSelectedTools?: UserSelectedTools; + isSystemInitiated?: boolean; + systemInitiatedLabel?: string; } export class ChatRequestModel implements IChatRequestModel { @@ -354,6 +358,8 @@ export class ChatRequestModel implements IChatRequestModel { public readonly modelId?: string; public readonly modeInfo?: IChatRequestModeInfo; public readonly userSelectedTools?: UserSelectedTools; + public readonly isSystemInitiated?: boolean; + public readonly systemInitiatedLabel?: string; private readonly _shouldBeBlocked = observableValue(this, false); public get shouldBeBlocked(): IObservable { @@ -425,6 +431,8 @@ export class ChatRequestModel implements IChatRequestModel { this.id = params.restoredId ?? 'request_' + generateUuid(); this._editedFileEvents = params.editedFileEvents; this.userSelectedTools = params.userSelectedTools; + this.isSystemInitiated = params.isSystemInitiated; + this.systemInitiatedLabel = params.systemInitiatedLabel; } adoptTo(session: ChatModel) { @@ -1510,6 +1518,8 @@ export interface ISerializableChatRequestData extends ISerializableChatResponseD editedFileEvents?: IChatAgentEditedFileEvent[]; modelId?: string; modeInfo?: IChatRequestModeInfo; + isSystemInitiated?: boolean; + systemInitiatedLabel?: string; } export interface ISerializableMarkdownInfo { @@ -2375,6 +2385,8 @@ export class ChatModel extends Disposable implements IChatModel { editedFileEvents: raw.editedFileEvents, modelId: raw.modelId, modeInfo: raw.modeInfo, + isSystemInitiated: raw.isSystemInitiated, + systemInitiatedLabel: raw.systemInitiatedLabel, }); request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts @@ -2543,7 +2555,7 @@ export class ChatModel extends Disposable implements IChatModel { this._onDidChange.fire({ kind: 'setHidden' }); } - addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, modeInfo?: IChatRequestModeInfo, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], isCompleteAddedRequest?: boolean, modelId?: string, userSelectedTools?: UserSelectedTools, id?: string): ChatRequestModel { + addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, modeInfo?: IChatRequestModeInfo, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], isCompleteAddedRequest?: boolean, modelId?: string, userSelectedTools?: UserSelectedTools, id?: string, isSystemInitiated?: boolean, systemInitiatedLabel?: string): ChatRequestModel { const editedFileEvents = [...this.currentEditedFileEvents.values()]; this.currentEditedFileEvents.clear(); const request = new ChatRequestModel({ @@ -2561,6 +2573,8 @@ export class ChatModel extends Disposable implements IChatModel { modelId, editedFileEvents: editedFileEvents.length ? editedFileEvents : undefined, userSelectedTools, + isSystemInitiated, + systemInitiatedLabel, }); request.response = new ChatResponseModel({ responseContent: [], @@ -2718,6 +2732,8 @@ export class ChatModel extends Disposable implements IChatModel { editedFileEvents: r.editedFileEvents, modelId: r.modelId, modeInfo: r.modeInfo, + isSystemInitiated: r.isSystemInitiated || undefined, + systemInitiatedLabel: r.systemInitiatedLabel, ...r.response?.toJSON(), }; }), diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index df6f2f227a11d..c1227b9423be2 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -151,6 +151,8 @@ const requestSchema = Adapt.object m.response?.codeCitations, objectsEqual), timeSpentWaiting: Adapt.v(m => m.response?.timestamp), // based on response timestamp modeInfo: Adapt.v(m => m.modeInfo, objectsEqual), + isSystemInitiated: Adapt.v(m => m.isSystemInitiated), + systemInitiatedLabel: Adapt.v(m => m.systemInitiatedLabel), }, { sealed: (o) => o.modelState?.value === ResponseModelState.Cancelled || o.modelState?.value === ResponseModelState.Failed || o.modelState?.value === ResponseModelState.Complete, }); diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index e69e3d29079f6..84577df908ef9 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -98,6 +98,8 @@ export interface IChatRequestViewModel { readonly timestamp: number; /** The kind of pending request, or undefined if not pending */ readonly pendingKind?: ChatRequestQueueKind; + readonly isSystemInitiated?: boolean; + readonly systemInitiatedLabel?: string; } export interface IChatResponseMarkdownRenderData { @@ -334,7 +336,12 @@ export class ChatViewModel extends Disposable implements IChatViewModel { } getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] { - let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop); + let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => { + if (item.shouldBeRemovedOnSend && !item.shouldBeRemovedOnSend.afterUndoStop) { + return false; + } + return true; + }); if (this._options?.maxVisibleItems !== undefined && items.length > this._options.maxVisibleItems) { items = items.slice(-this._options.maxVisibleItems); } @@ -477,6 +484,14 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._pendingKind; } + get isSystemInitiated() { + return this._model.isSystemInitiated; + } + + get systemInitiatedLabel() { + return this._model.systemInitiatedLabel; + } + constructor( private readonly _model: IChatRequestModel, private readonly _pendingKind?: ChatRequestQueueKind, diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 42b418e34b044..302ccc33a8cfa 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -181,6 +181,10 @@ export interface IChatAgentRequest { */ parentRequestId?: string; + /** + * When true, this request was initiated by the system rather than the user. + */ + isSystemInitiated?: boolean; } export interface IChatQuestion { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts index b4a0ffc155cf1..c760e13185c0c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts @@ -16,7 +16,6 @@ import { IEditorWorkerService } from '../../../../../../editor/common/services/e import { IResolvedTextEditorModel, ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { IToolCallState, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ContentEncoding, IWriteFileParams } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { IToolCallCompletedState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileContent, IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -70,8 +69,14 @@ function makeToolCall(opts: { confirmed: ToolCallConfirmationReason.NotNeeded, content: [{ type: ToolResultContentType.FileEdit, - beforeURI: opts.beforeURI, - afterURI: opts.afterURI, + before: { + uri: URI.file(opts.filePath).toString(), + content: { uri: opts.beforeURI }, + }, + after: { + uri: URI.file(opts.filePath).toString(), + content: { uri: opts.afterURI }, + }, diff: { added: opts.added ?? 0, removed: opts.removed ?? 0, @@ -90,6 +95,21 @@ function makeMockFileService(contentMap: Map): IFileService { } return { value: VSBuffer.fromString(data) } as IFileContent; } + override async writeFile(uri: URI, content: VSBuffer): Promise { + contentMap.set(uri.toString(), content.toString()); + return {}; + } + override async del(uri: URI) { + contentMap.delete(uri.toString()); + } + override async move(source: URI, target: URI): Promise { + const data = contentMap.get(source.toString()); + if (data !== undefined) { + contentMap.set(target.toString(), data); + contentMap.delete(source.toString()); + } + return {}; + } }; } @@ -305,56 +325,50 @@ suite('AgentHostEditingSession', () => { suite('undo/redo', () => { - test('undo fires writeFile with before-content and updates state', async () => { - const beforeUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); + test('undo writes before-content to file and updates state', async () => { + const beforeContentUri = toAgentHostUri(URI.parse('content://before-1'), 'local'); + const fileUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); const contentMap = new Map(); - contentMap.set(beforeUri.toString(), 'before-content'); + contentMap.set(beforeContentUri.toString(), 'before-content'); + contentMap.set(fileUri.toString(), 'current-content'); const session = createSession(store, contentMap); session.addToolCallEdits('req-1', makeToolCall({ toolCallId: 'tc-1', filePath: '/workspace/file.ts', - beforeURI: URI.file('/workspace/file.ts').toString(), + beforeURI: 'content://before-1', afterURI: 'content://after-1', })); - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.undoInteraction(); - assert.strictEqual(writes.length, 1); - assert.strictEqual(writes[0].data, 'before-content'); - assert.strictEqual(writes[0].encoding, ContentEncoding.Utf8); + assert.strictEqual(contentMap.get(fileUri.toString()), 'before-content'); assert.strictEqual(session.canUndo.get(), false); assert.strictEqual(session.canRedo.get(), true); assert.deepStrictEqual(session.entries.get(), []); }); - test('redo fires writeFile with after-content', async () => { - const beforeUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); - const afterUri = toAgentHostUri(URI.parse('content://after-1'), 'local'); + test('redo writes after-content to file', async () => { + const beforeContentUri = toAgentHostUri(URI.parse('content://before-1'), 'local'); + const afterContentUri = toAgentHostUri(URI.parse('content://after-1'), 'local'); + const fileUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); const contentMap = new Map(); - contentMap.set(beforeUri.toString(), 'before-content'); - contentMap.set(afterUri.toString(), 'after-content'); + contentMap.set(beforeContentUri.toString(), 'before-content'); + contentMap.set(afterContentUri.toString(), 'after-content'); + contentMap.set(fileUri.toString(), 'current-content'); const session = createSession(store, contentMap); session.addToolCallEdits('req-1', makeToolCall({ toolCallId: 'tc-1', filePath: '/workspace/file.ts', - beforeURI: URI.file('/workspace/file.ts').toString(), + beforeURI: 'content://before-1', afterURI: 'content://after-1', })); await session.undoInteraction(); - - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.redoInteraction(); - assert.strictEqual(writes.length, 1); - assert.strictEqual(writes[0].data, 'after-content'); + assert.strictEqual(contentMap.get(fileUri.toString()), 'after-content'); assert.strictEqual(session.canUndo.get(), true); assert.strictEqual(session.canRedo.get(), false); assert.strictEqual(session.entries.get().length, 1); @@ -363,12 +377,8 @@ suite('AgentHostEditingSession', () => { test('undo when nothing to undo is no-op', async () => { const session = createSession(store, new Map()); - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.undoInteraction(); - - assert.strictEqual(writes.length, 0); + // No assertions needed — just verifying no throw }); test('redo when nothing to redo is no-op', async () => { @@ -381,26 +391,22 @@ suite('AgentHostEditingSession', () => { afterURI: 'a', })); - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.redoInteraction(); - - assert.strictEqual(writes.length, 0); + // No assertions needed — just verifying no throw }); test('undo after multiple checkpoints removes entries correctly', async () => { const contentMap = new Map(); - contentMap.set(toAgentHostUri(URI.file('/workspace/a.ts'), 'local').toString(), 'a-before'); - contentMap.set(toAgentHostUri(URI.file('/workspace/b.ts'), 'local').toString(), 'b-before'); - contentMap.set(toAgentHostUri(URI.parse('content://after-a'), 'local').toString(), 'a-after'); - contentMap.set(toAgentHostUri(URI.parse('content://after-b'), 'local').toString(), 'b-after'); + contentMap.set(toAgentHostUri(URI.parse('content://before-a'), 'local').toString(), 'a-before'); + contentMap.set(toAgentHostUri(URI.parse('content://before-b'), 'local').toString(), 'b-before'); + contentMap.set(toAgentHostUri(URI.file('/workspace/a.ts'), 'local').toString(), 'a-current'); + contentMap.set(toAgentHostUri(URI.file('/workspace/b.ts'), 'local').toString(), 'b-current'); const session = createSession(store, contentMap); session.addToolCallEdits('req-1', makeToolCall({ toolCallId: 'tc-1', filePath: '/workspace/a.ts', - beforeURI: URI.file('/workspace/a.ts').toString(), + beforeURI: 'content://before-a', afterURI: 'content://after-a', added: 5, })); @@ -408,7 +414,7 @@ suite('AgentHostEditingSession', () => { session.addToolCallEdits('req-2', makeToolCall({ toolCallId: 'tc-2', filePath: '/workspace/b.ts', - beforeURI: URI.file('/workspace/b.ts').toString(), + beforeURI: 'content://before-b', afterURI: 'content://after-b', added: 3, })); @@ -769,13 +775,8 @@ suite('AgentHostEditingSession', () => { })); // Restore to before req-2 — should undo req-2's edits - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.restoreSnapshot('req-2', undefined); - // req-2 has a tool checkpoint whose before-content should be written - assert.ok(writes.length > 0); // Entries should only show req-1's edits assert.strictEqual(session.entries.get().length, 1); assert.strictEqual(session.entries.get()[0].lastModifyingRequestId, 'req-1'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 64ee099bd7da3..5f249eb4466fa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -276,15 +276,21 @@ suite('stateToProgressAdapter', () => { toolInput: JSON.stringify({ path: '/home/user/file.ts' }), content: [{ type: ToolResultContentType.FileEdit, - beforeURI: 'agenthost-content:///session/snap/before', - afterURI: 'agenthost-content:///session/snap/after', + before: { + uri: URI.file('/home/user/file.ts').toString(), + content: { uri: 'agenthost-content:///session/snap/before' }, + }, + after: { + uri: URI.file('/home/user/file.ts').toString(), + content: { uri: 'agenthost-content:///session/snap/after' }, + }, }], }); assert.strictEqual(fileEdits.length, 1); assert.strictEqual(fileEdits[0].resource.fsPath.replace(/\\/g, '/'), '/home/user/file.ts'); - assert.strictEqual(fileEdits[0].beforeContentUri.toString(), URI.parse('agenthost-content:///session/snap/before').toString()); - assert.strictEqual(fileEdits[0].afterContentUri.toString(), URI.parse('agenthost-content:///session/snap/after').toString()); + assert.strictEqual(fileEdits[0].beforeContentUri?.toString(), URI.parse('agenthost-content:///session/snap/before').toString()); + assert.strictEqual(fileEdits[0].afterContentUri?.toString(), URI.parse('agenthost-content:///session/snap/after').toString()); assert.ok(fileEdits[0].undoStopId); }); @@ -324,7 +330,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(fileEdits.length, 0); }); - test('returns empty file edits when toolInput has no path', () => { + test('returns empty file edits when FileEdit has no before or after', () => { const tc = createToolCallState({ status: ToolCallStatus.Running }); const invocation = toolCallStateToInvocation(tc); @@ -340,36 +346,39 @@ suite('stateToProgressAdapter', () => { toolInput: JSON.stringify({ content: 'no path field' }), content: [{ type: ToolResultContentType.FileEdit, - beforeURI: 'agenthost-content:///before', - afterURI: 'agenthost-content:///after', }], }); assert.strictEqual(fileEdits.length, 0); }); - test('returns empty file edits when toolInput is invalid JSON', () => { + test('returns file edit for create (only after present)', () => { const tc = createToolCallState({ status: ToolCallStatus.Running }); const invocation = toolCallStateToInvocation(tc); const fileEdits = finalizeToolInvocation(invocation, { status: ToolCallStatus.Completed, toolCallId: 'tc-1', - toolName: 'edit_file', - displayName: 'Edit File', - invocationMessage: 'Editing file...', + toolName: 'create_file', + displayName: 'Create File', + invocationMessage: 'Creating file...', confirmed: ToolCallConfirmationReason.NotNeeded, success: true, - pastTenseMessage: 'Edited', - toolInput: 'not json', + pastTenseMessage: 'Created file', content: [{ type: ToolResultContentType.FileEdit, - beforeURI: 'agenthost-content:///before', - afterURI: 'agenthost-content:///after', + after: { + uri: URI.file('/home/user/new-file.ts').toString(), + content: { uri: 'agenthost-content:///snap/after' }, + }, }], }); - assert.strictEqual(fileEdits.length, 0); + assert.strictEqual(fileEdits.length, 1); + assert.strictEqual(fileEdits[0].kind, 'create'); + assert.strictEqual(fileEdits[0].resource.fsPath.replace(/\\/g, '/'), '/home/user/new-file.ts'); + assert.strictEqual(fileEdits[0].beforeContentUri, undefined); + assert.ok(fileEdits[0].afterContentUri); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 564473138de35..ac68d4cde44f9 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -461,6 +461,60 @@ suite('ChatService', () => { await assertSnapshot(toSnapshotExportData(chatModel2)); }); + test('can serialize and deserialize implicit request flag', async () => { + let serializedChatData: ISerializableChatData; + + { + const testService = createChatService(); + const chatModel1Ref = testDisposables.add(startSessionModel(testService)); + const chatModel1 = chatModel1Ref.object; + + const response = await testService.sendRequest(chatModel1.sessionResource, 'test implicit request', { isSystemInitiated: true }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + assert.strictEqual(chatModel1.getRequests().length, 1); + assert.strictEqual(chatModel1.getRequests()[0].isSystemInitiated, true); + + serializedChatData = JSON.parse(JSON.stringify(chatModel1)); + assert.strictEqual(serializedChatData.requests.length, 1); + assert.strictEqual(serializedChatData.requests[0].isSystemInitiated, true); + } + + const testService2 = createChatService(); + const chatModel2Ref = testService2.loadSessionFromData(serializedChatData); + assert(chatModel2Ref); + testDisposables.add(chatModel2Ref); + const chatModel2 = chatModel2Ref.object; + + assert.strictEqual(chatModel2.getRequests().length, 1); + assert.strictEqual(chatModel2.getRequests()[0].isSystemInitiated, true); + }); + + test('acquireExistingSession keeps model alive for steering request after refs released', async () => { + const testService = createChatService(); + const modelRef = startSessionModel(testService); + const sessionResource = modelRef.object.sessionResource; + + // Acquire a keep-alive reference (what the fix does) + const keepAliveRef = testDisposables.add(testService.acquireExistingSession(sessionResource, 'test#keepAlive')!); + assert.ok(keepAliveRef, 'acquireExistingSession should return a reference'); + + // Release the original reference to simulate user navigating away + modelRef.dispose(); + await testService.waitForModelDisposals(); + + // Model should still be accessible because keepAliveRef holds it + const response = await testService.sendRequest(sessionResource, 'terminal completed', { + queue: ChatRequestQueueKind.Steering, + isSystemInitiated: true, + }); + assert.strictEqual(response.kind, 'queued'); + + // Clean up + keepAliveRef.dispose(); + }); + test('onDidDisposeSession', async () => { const testService = createChatService(); const modelRef = testService.startNewLocalSession(ChatAgentLocation.Chat); @@ -994,7 +1048,7 @@ function toSnapshotExportData(model: IChatModel) { ...exp, requests: exp.requests.map(r => { // Destructure properties after `vote` so we can insert `voteDownReason` in the correct position for snapshot compat - const { slashCommand, usedContext, contentReferences, codeCitations, timeSpentWaiting, ...rest } = r; + const { slashCommand, usedContext, contentReferences, codeCitations, timeSpentWaiting, isSystemInitiated: _isSystemInitiated, systemInitiatedLabel: _systemInitiatedLabel, ...rest } = r; return { ...rest, modelState: { diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts index 1184ba6cdf9df..4123eb8fc5088 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts @@ -282,6 +282,12 @@ export class NotebookFindInput extends FindInput { this._findFilter.applyStyles(this._filterChecked); } + protected override getToggleDomNodes(): HTMLElement[] { + const nodes = super.getToggleDomNodes(); + nodes.push(this._findFilter.container); + return nodes; + } + getCellToolbarActions(menu: IMenu): { primary: IAction[]; secondary: IAction[] } { return getActionBarActions(menu.getActions({ shouldForwardArgs: true }), g => /^inline/.test(g)); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index 625a9608bdca1..6b0dccf6065bc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -20,7 +20,7 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi return undefined; } - const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution); + const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell); return { rewritten: wrappedCommand.command, reasoning: wrappedCommand.requiresUnsandboxConfirmation ? 'Switched command to unsandboxed execution because the command includes a domain that is not in the sandbox allowlist' : 'Wrapped command for sandbox execution', diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index a2357b7bd2fdf..22e43ee8a2c58 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -6,17 +6,19 @@ import type { IMarker as XtermMarker } from '@xterm/xterm'; import { IAction } from '../../../../../../../base/common/actions.js'; import { timeout, type MaybePromise } from '../../../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; -import { Disposable, MutableDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { isObject, isString } from '../../../../../../../base/common/types.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { localize } from '../../../../../../../nls.js'; import { IChatWidgetService } from '../../../../../chat/browser/chat.js'; import { ChatElicitationRequestPart } from '../../../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; -import { ChatModel } from '../../../../../chat/common/model/chatModel.js'; +import { ChatModel, ChatRequestModel } from '../../../../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../../../../chat/common/chatService/chatService.js'; +import { ChatRequestTextPart } from '../../../../../chat/common/requestParser/chatParserTypes.js'; +import { OffsetRange } from '../../../../../../../editor/common/core/ranges/offsetRange.js'; import { ChatAgentLocation, ChatPermissionLevel } from '../../../../../chat/common/constants.js'; import { ChatMessageRole, getTextResponseFromStream, type ILanguageModelChatSelector, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; import { IToolInvocationContext } from '../../../../../chat/common/tools/languageModelToolsService.js'; @@ -111,6 +113,11 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { /** The chat session resource for this tool invocation, used to check permission level. */ private readonly _sessionResource: URI | undefined; + private _asyncMode = false; + private _command = ''; + private _invocationContext: IToolInvocationContext | undefined; + private _currentMonitoringCts: CancellationTokenSource | undefined; + constructor( private readonly _execution: IExecution, private readonly _pollFn: ((execution: IExecution, token: CancellationToken, taskService: ITaskService) => Promise) | undefined, @@ -128,10 +135,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { super(); this._sessionResource = invocationContext?.sessionResource; + this._command = command; + this._invocationContext = invocationContext; + this._register(toDisposable(() => this._currentMonitoringCts?.dispose())); // Start async to ensure listeners are set up timeout(0).then(() => { - this._startMonitoring(command, invocationContext, token); + this._currentMonitoringCts = new CancellationTokenSource(token); + this._startMonitoring(command, invocationContext, this._currentMonitoringCts.token); }); } @@ -162,6 +173,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { extended = true; this._state = OutputMonitorState.PollingForIdle; continue; + } else if (this._asyncMode) { + // In async mode, wait for new data instead of stopping on timeout + this._logService.trace('OutputMonitor: Async mode - timeout reached, waiting for new terminal data'); + extended = false; + await this._waitForNewData(token); + if (token.isCancellationRequested) { + break; + } + this._state = OutputMonitorState.PollingForIdle; + continue; } else { this._promptPart?.hide(); this._promptPart = undefined; @@ -177,6 +198,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._logService.trace('OutputMonitor: Idle handler -> continue polling'); this._state = OutputMonitorState.PollingForIdle; continue; + } else if (this._asyncMode) { + // In async mode, wait for new terminal data before monitoring again. + // This avoids expensive LLM calls while the terminal sits idle. + this._logService.trace('OutputMonitor: Async mode - waiting for new terminal data before next monitoring cycle'); + await this._waitForNewData(token); + if (token.isCancellationRequested) { + break; + } + this._state = OutputMonitorState.PollingForIdle; + continue; } else { this._logService.trace(`OutputMonitor: Idle handler -> stop polling (hasResources=${!!idleResult.resources}, outputLen=${idleResult.output?.length ?? 0})`); resources = idleResult.resources; @@ -216,6 +247,54 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } } + /** + * Continues monitoring in background mode with a new cancellation token. + * In background mode, the monitor re-polls for idle and handles prompts + * whenever new terminal data arrives, rather than stopping after the first + * idle detection. Resource cost is bounded because the monitor only wakes + * on new terminal data (via {@link _waitForNewData}) and each idle cycle + * is capped by the standard polling timeouts. + */ + continueMonitoringAsync(token: CancellationToken): void { + this._asyncMode = true; + // Cancel and dispose any in-progress monitoring run to avoid two concurrent loops + this._currentMonitoringCts?.dispose(); + this._currentMonitoringCts = new CancellationTokenSource(token); + this._state = OutputMonitorState.PollingForIdle; + this._startMonitoring(this._command, this._invocationContext, this._currentMonitoringCts.token); + } + + /** + * Waits for new terminal data or cancellation. Used in background mode + * to avoid polling and LLM calls while the terminal is quiet. + */ + private _waitForNewData(token: CancellationToken): Promise { + return new Promise(resolve => { + if (token.isCancellationRequested) { + resolve(); + return; + } + const cleanup = () => { + dataListener.dispose(); + tokenListener.dispose(); + disposedListener.dispose(); + }; + const dataListener = this._execution.instance.onData(() => { + cleanup(); + resolve(); + }); + const tokenListener = token.onCancellationRequested(() => { + cleanup(); + resolve(); + }); + // Resolve when the terminal instance is disposed to avoid waiting forever + const disposedListener = this._execution.instance.onDisposed(() => { + cleanup(); + resolve(); + }); + }); + } + private async _handleIdleState(token: CancellationToken): Promise<{ resources?: ILinkLocation[]; shouldContinuePolling: boolean; output?: string }> { const output = this._execution.getOutput(this._lastPromptMarker); @@ -277,6 +356,38 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return { shouldContinuePolling: true }; } + // In async mode, skip the LLM-based prompt detection to avoid expensive calls + // on every idle cycle. Instead, use regex-based detection for input-required + // patterns (passwords, [Y/n], etc.) which were already detected in _waitForIdle + // but need elicitation UI shown here. + if (this._asyncMode) { + if (detectsInputRequiredPattern(output)) { + this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, showing free-form input'); + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts) || this._isAutopilotMode(); + if (!autoReply) { + const currentMarker = this._execution.instance.registerMarker(); + if (currentMarker) { + this._lastPromptMarker = currentMarker; + } + this._cleanupIdleInputListener(); + this._outputMonitorTelemetryCounters.inputToolFreeFormInputShownCount++; + const lastLine = output.trimEnd().split(/\r?\n/).pop() || ''; + const receivedTerminalInput = await this._requestFreeFormTerminalInput(token, this._execution, { + prompt: lastLine, + options: [], + detectedRequestForFreeFormInput: true + }); + if (receivedTerminalInput) { + this._logService.trace('OutputMonitor: Async mode - free-form input received, continue polling'); + await timeout(200); + return { shouldContinuePolling: true }; + } + } + } + this._cleanupIdleInputListener(); + return { shouldContinuePolling: false, output }; + } + this._logService.trace('OutputMonitor: Determining user input options via language model'); const confirmationPrompt = await this._determineUserInputOptions(this._execution, token); this._logService.trace(`OutputMonitor: Input options result: ${confirmationPrompt ? `prompt=${this._formatLastLineForLog(confirmationPrompt.prompt)}, options=${confirmationPrompt.options.length} ${this._formatOptionsForLog(confirmationPrompt.options)}, freeForm=${!!confirmationPrompt.detectedRequestForFreeFormInput}` : 'none'}`); @@ -395,7 +506,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { try { while (!token.isCancellationRequested && waited < maxWaitMs) { const waitTime = Math.min(currentInterval, maxWaitMs - waited); - await timeout(waitTime, token); + try { + await timeout(waitTime, token); + } catch (err) { + if (token.isCancellationRequested) { + return OutputMonitorState.Cancelled; + } + throw err; + } waited += waitTime; currentInterval = Math.min(currentInterval * 2, maxInterval); const currentOutput = execution.getOutput(); @@ -843,11 +961,42 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (!(chatModel instanceof ChatModel)) { throw new Error('No model'); } - const request = chatModel.getRequests().at(-1); + // In async mode the last request may be an implicit (hidden) steering request. + // Attach the elicitation to the last visible request so it renders in the UI. + const requests = chatModel.getRequests(); + let request: ChatRequestModel | undefined; + if (this._asyncMode) { + // In async mode the previous response is already complete. + // Create a new system-initiated request so the data model properly + // represents a finished response followed by a new request/response + // rather than reopening the completed response. + const message = localize('terminalPromptDetected', "Terminal is waiting for input"); + const parts = [new ChatRequestTextPart(new OffsetRange(0, message.length), { startColumn: 1, startLineNumber: 1, endColumn: 1, endLineNumber: 1 }, message)]; + request = chatModel.addRequest( + { text: message, parts }, + { variables: [] }, + 0, // attempt + undefined, // modeInfo + undefined, // chatAgent + undefined, // slashCommand + undefined, // confirmation + undefined, // locationData + undefined, // attachments + undefined, // isCompleteAddedRequest + undefined, // modelId + undefined, // userSelectedTools + undefined, // id + true, // isSystemInitiated + localize('backgroundTaskInputNeeded', "Background task `{0}` input needed", this._command), // systemInitiatedLabel + ); + } else { + request = requests.findLast(r => !r.isSystemInitiated) ?? requests.at(-1); + } if (!request) { throw new Error('No request'); } let part!: ChatElicitationRequestPart; + const asyncRequest = this._asyncMode ? request : undefined; const promise = new Promise(resolve => { const thePart = part = new ChatElicitationRequestPart( title, @@ -869,6 +1018,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } thePart.hide(); this._promptPart = undefined; + asyncRequest?.response?.complete(); return ElicitationState.Accepted; }, async () => { @@ -885,6 +1035,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } thePart.hide(); this._promptPart = undefined; + asyncRequest?.response?.complete(); return ElicitationState.Rejected; }, undefined, // source @@ -896,7 +1047,10 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._promptPart = thePart; }); - this._register(token.onCancellationRequested(() => part.hide())); + this._register(token.onCancellationRequested(() => { + part.hide(); + asyncRequest?.response?.complete(); + })); return { promise, part }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index a6abdf30da9b4..a043fb68e8608 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -27,7 +27,10 @@ import { ICommandDetectionCapability, TerminalCapability } from '../../../../../ import { ITerminalLogService, ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js'; -import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; +import { IChatService, ChatRequestQueueKind, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; +import { constObservable, type IObservable } from '../../../../../../base/common/observable.js'; +import type { IChatRequestModeInfo } from '../../../../chat/common/model/chatModel.js'; +import type { UserSelectedTools } from '../../../../chat/common/participants/chatAgents.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IStreamedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; @@ -83,7 +86,7 @@ const TERMINAL_SANDBOX_DOCUMENTATION_URL = 'https://aka.ms/vscode-sandboxing'; const TOOL_REFERENCE_NAME = 'runInTerminal'; const LEGACY_TOOL_REFERENCE_FULL_NAMES = ['runCommands/runInTerminal']; -function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const isWinPwsh = isWindowsPowerShell(shell); const parts = [ `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, @@ -133,7 +136,7 @@ function createPowerShellModelDescription(shell: string, isSandboxEnabled: boole '- Use Test-Path to check file/directory existence', '- Be specific with Select-Object properties to avoid excessive output', '- Avoid printing credentials unless absolutely required', - `- NEVER run Start-Sleep or similar wait commands. If you need to check on an async process, use ${TerminalToolId.GetTerminalOutput} instead`, + `- NEVER run Start-Sleep or similar wait commands.${backgroundNotifications ? ' You will be automatically notified on your next turn when async terminal commands complete or need input.' : ''} Use ${TerminalToolId.GetTerminalOutput} to check output before then`, ); return parts.join('\n'); @@ -165,7 +168,7 @@ function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDoma return lines; } -function createGenericDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createGenericDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const parts = [` Command Execution: - Use && to chain simple commands on one line @@ -206,25 +209,25 @@ Best Practices: - Use find with -exec or xargs for file operations - Be specific with commands to avoid excessive output - Avoid printing credentials unless absolutely required -- NEVER run sleep or similar wait commands in a terminal. If you need to check on an async process, use ${TerminalToolId.GetTerminalOutput} instead`); +- NEVER run sleep or similar wait commands in a terminal.${backgroundNotifications ? ' You will be automatically notified on your next turn when async terminal commands complete or need input.' : ''} Use ${TerminalToolId.GetTerminalOutput} to check output before then`); return parts.join(''); } -function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createBashModelDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, networkDomains), + createGenericDescription(isSandboxEnabled, backgroundNotifications, networkDomains), '- Use [[ ]] for conditional tests instead of [ ]', '- Prefer $() over backticks for command substitution', '- Use set -e at start of complex commands to exit on errors' ].join('\n'); } -function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createZshModelDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, networkDomains), + createGenericDescription(isSandboxEnabled, backgroundNotifications, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use [[ ]] for conditional tests instead of [ ]', @@ -234,10 +237,10 @@ function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: I ].join('\n'); } -function createFishModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createFishModelDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent fish terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, networkDomains), + createGenericDescription(isSandboxEnabled, backgroundNotifications, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use test expressions for conditionals (no [[ ]] syntax)', @@ -253,6 +256,7 @@ export async function createRunInTerminalToolData( ): Promise { const instantiationService = accessor.get(IInstantiationService); const terminalSandboxService = accessor.get(ITerminalSandboxService); + const configurationService = accessor.get(IConfigurationService); const profileFetcher = instantiationService.createInstance(TerminalProfileFetcher); const [shell, os, isSandboxEnabled] = await Promise.all([ @@ -262,16 +266,17 @@ export async function createRunInTerminalToolData( ]); const networkDomains = isSandboxEnabled ? terminalSandboxService.getResolvedNetworkDomains() : undefined; + const backgroundNotifications = configurationService.getValue(TerminalChatAgentToolsSettingId.BackgroundNotifications) === true; let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { - modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, networkDomains); + modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, backgroundNotifications, networkDomains); } else if (shell && os && isZsh(shell, os)) { - modelDescription = createZshModelDescription(isSandboxEnabled, networkDomains); + modelDescription = createZshModelDescription(isSandboxEnabled, backgroundNotifications, networkDomains); } else if (shell && os && isFish(shell, os)) { - modelDescription = createFishModelDescription(isSandboxEnabled, networkDomains); + modelDescription = createFishModelDescription(isSandboxEnabled, backgroundNotifications, networkDomains); } else { - modelDescription = createBashModelDescription(isSandboxEnabled, networkDomains); + modelDescription = createBashModelDescription(isSandboxEnabled, backgroundNotifications, networkDomains); } const sharedProperties: IJSONSchemaMap = { @@ -304,7 +309,7 @@ export async function createRunInTerminalToolData( toolReferenceName: TOOL_REFERENCE_NAME, legacyToolReferenceFullNames: LEGACY_TOOL_REFERENCE_FULL_NAMES, displayName: localize('runInTerminalTool.displayName', 'Run in Terminal'), - modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion up to timeout; if still running, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID.`, + modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion up to timeout; if still running, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID.${backgroundNotifications ? `\n\nAsync terminal notifications: When a command finishes in an async terminal, you will be automatically notified on your next turn with the exit code and terminal output. You will also be notified if the terminal needs input. Use ${TerminalToolId.GetTerminalOutput} to check output before then. Do NOT poll or sleep to wait for completion.` : `\n\nUse ${TerminalToolId.GetTerminalOutput} to check on async terminal output. Do NOT poll or sleep to wait for completion.`}`, userDescription: localize('runInTerminalTool.userDescription', 'Run commands in the terminal'), source: ToolDataSource.Internal, icon: Codicon.terminal, @@ -1078,7 +1083,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let exitCode: number | undefined; let altBufferResult: IToolResult | undefined; let didTimeout = false; - let didMoveToBackground = executionOptions.persistentSession; + // Covers both terminals that start as background (persistentSession) and + // foreground terminals that later move to background (timeout/continue-in-bg). + let isBackgroundExecution = executionOptions.persistentSession; let timeoutPromise: CancelablePromise | undefined; let timeoutRacePromise: Promise<{ type: 'timeout' }> | undefined; let outputMonitor: OutputMonitor | undefined; @@ -1108,7 +1115,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (sessionId === terminalToolSessionId) { const execution = RunInTerminalTool._activeExecutions.get(termId); execution?.setBackground?.(); - didMoveToBackground = true; + isBackgroundExecution = true; // Resolve the race promise instead of cancelling - this allows the execution // to continue running so it can be awaited later continueInBackgroundResolve?.(); @@ -1144,7 +1151,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { : undefined; store.add(execution.strategy.onDidCreateStartMarker(startMarker => { if (!outputMonitor) { - outputMonitor = store.add(this._instantiationService.createInstance( + outputMonitor = this._instantiationService.createInstance( OutputMonitor, { instance: toolTerminal.instance, @@ -1155,7 +1162,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { invocation.context, token, command - )); + ); } })); @@ -1238,7 +1245,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); error = 'timeout'; didTimeout = true; - didMoveToBackground = true; + isBackgroundExecution = true; toolTerminal.isBackground = true; this._sessionTerminalAssociations.delete(chatSessionResource); await this._associateProcessIdWithSession(toolTerminal.instance, chatSessionResource, termId, toolTerminal.shellIntegrationQuality, true); @@ -1308,7 +1315,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didTimeout && e instanceof CancellationError) { this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); error = 'timeout'; - didMoveToBackground = true; + isBackgroundExecution = true; toolTerminal.isBackground = true; this._sessionTerminalAssociations.delete(chatSessionResource); const timeoutOutput = getOutput(toolTerminal.instance, undefined); @@ -1338,17 +1345,25 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } finally { timeoutPromise?.cancel(); - if (didMoveToBackground && executionPromise) { - // Execution moved to background - attach error handler since we won't await it + if (isBackgroundExecution && executionPromise) { + // Background terminal (started as bg or moved to bg) - attach error handler since we won't await it executionPromise.catch((e: unknown) => { if (!(e instanceof CancellationError)) { this._logService.error(`RunInTerminalTool: Background execution error`, e); } }); + // Register a listener to notify the agent when commands complete in this + // background terminal, and continue the output monitor for prompt-for-input detection + if (this._configurationService.getValue(TerminalChatAgentToolsSettingId.BackgroundNotifications)) { + this._registerCompletionNotification(toolTerminal.instance, termId, chatSessionResource, command, outputMonitor); + } else { + outputMonitor?.dispose(); + } } else { - // Foreground completed or error - clean up execution + // Foreground completed or error - clean up execution and output monitor RunInTerminalTool._activeExecutions.get(termId)?.dispose(); RunInTerminalTool._activeExecutions.delete(termId); + outputMonitor?.dispose(); } store.dispose(); const timingExecuteMs = Date.now() - timingStart; @@ -1392,12 +1407,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } else if (didToolEditCommand) { resultText.push(`Note: The tool simplified the command to \`${command}\`, and this is the output of running that command instead:\n`); } - if (didMoveToBackground && !executionOptions.persistentSession) { + if (isBackgroundExecution && !executionOptions.persistentSession) { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { - resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}. Use ${TerminalToolId.GetTerminalOutput} to check its current output, ${TerminalToolId.SendToTerminal} to send further input, or ${TerminalToolId.KillTerminal} to stop it. Do NOT use sleep or manual polling to wait.\n\n`); + const notificationHint = this._configurationService.getValue(TerminalChatAgentToolsSettingId.BackgroundNotifications) + ? ' You will be automatically notified on your next turn when it completes.' + : ''; + resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint} Use ${TerminalToolId.GetTerminalOutput} to check output before then, ${TerminalToolId.SendToTerminal} to send further input, or ${TerminalToolId.KillTerminal} to stop it. Do NOT use sleep or manual polling to wait.\n\n`); } const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { @@ -1452,15 +1470,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { * Returns data content parts for any found images that exist on disk. */ private async _extractImagesFromOutput(output: string, cwd: URI | undefined): Promise { - const normalizedOutput = output.replace(/\r?\n/g, ''); - - // Match paths ending with image extensions. A leading / or \ is sufficient - // to identify a path segment; the full path up to the extension is captured. - const pathPattern = /(?:[^\s]*[\/\\][^\s]*\.(?:png|jpe?g|gif|webp|bmp))/gi; + // Match paths containing at least one / or \ and ending with an image + // extension. Each atom uses [^\s/\\]* so it cannot consume separators, + // which keeps the [/\\] tokens unambiguous and prevents catastrophic + // backtracking on long strings. + const pathPattern = /[^\s/\\]*(?:[/\\][^\s/\\]*)+\.(?:png|jpe?g|gif|webp|bmp)/gi; const matches = new Set(); - for (const match of normalizedOutput.matchAll(pathPattern)) { - matches.add(match[0]); + for (const line of output.split(/\r?\n/)) { + if (line.length > 10_000) { + continue; + } + for (const match of line.matchAll(pathPattern)) { + matches.add(match[0]); + } } if (matches.size === 0) { @@ -1754,6 +1777,114 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { RunInTerminalTool._activeExecutions.delete(termId); } } + + /** + * Registers a listener for command completion on a background terminal. + * When a command finishes, sends a steering message to the chat session + * so the agent is notified on its next turn. + * + * If an output monitor is provided, it is continued in background mode + * to detect prompts-for-input while the terminal runs in the background. + * The output monitor is cancelled and disposed when a command finishes. + */ + private _registerCompletionNotification(terminalInstance: ITerminalInstance, termId: string, chatSessionResource: URI, commandName: string, outputMonitor?: OutputMonitor): void { + const commandDetection = terminalInstance.capabilities.get(TerminalCapability.CommandDetection); + if (!commandDetection) { + outputMonitor?.dispose(); + return; + } + + // Acquire a reference to the ChatModel so it stays alive while we wait + // for the background terminal to complete. Without this, the model can + // be disposed if the user navigates away, and sendRequest would throw. + const sessionRef = this._chatService.acquireExistingSession(chatSessionResource, 'RunInTerminalTool#completionNotification'); + if (!sessionRef) { + this._logService.warn(`RunInTerminalTool: Cannot register completion notification for terminal ${termId} - session already disposed`); + outputMonitor?.dispose(); + return; + } + + // Capture model/mode/tools from the last request so the steering message + // uses the same settings as the original conversation (not defaults). + const lastRequest = sessionRef.object.lastRequest; + const sendOptions: { userSelectedModelId?: string; modeInfo?: IChatRequestModeInfo; userSelectedTools?: IObservable } = {}; + if (lastRequest) { + sendOptions.userSelectedModelId = lastRequest.modelId; + sendOptions.modeInfo = lastRequest.modeInfo; + if (lastRequest.userSelectedTools) { + sendOptions.userSelectedTools = constObservable(lastRequest.userSelectedTools); + } + } + + // Continue the output monitor in background mode for prompt-for-input detection. + // The monitor wakes only on new terminal data (not on a fixed interval), so + // resource cost is proportional to actual terminal activity. + let bgCts: CancellationTokenSource | undefined; + if (outputMonitor) { + bgCts = new CancellationTokenSource(); + outputMonitor.continueMonitoringAsync(bgCts.token); + } + + const listener = commandDetection.onCommandFinished(command => { + const execution = RunInTerminalTool._activeExecutions.get(termId); + if (!execution) { + cleanup(); + return; + } + + // Dispose after first notification to avoid chatty repeated messages + // if the user runs additional commands via send_to_terminal. + cleanup(); + + const exitCode = command.exitCode; + const exitCodeText = exitCode !== undefined ? ` with exit code ${exitCode}` : ''; + const currentOutput = execution.getOutput(); + const message = `[Terminal ${termId} notification: command completed${exitCodeText}. Use send_to_terminal to send another command or kill_terminal to stop it.]\nTerminal output:\n${currentOutput}`; + + this._logService.debug(`RunInTerminalTool: Command completed in background terminal ${termId}, notifying chat session`); + + this._chatService.sendRequest(chatSessionResource, message, { + queue: ChatRequestQueueKind.Steering, + isSystemInitiated: true, + systemInitiatedLabel: localize('backgroundTaskCompleted', "Background task `{0}` completed", commandName), + ...sendOptions, + }).catch(e => { + this._logService.warn(`RunInTerminalTool: Failed to send completion notification for terminal ${termId}`, e); + }); + }); + + // Clean up all background resources when the terminal is disposed + // (e.g. user closes the terminal) to avoid leaking listeners and monitors. + const disposedListener = terminalInstance.onDisposed(() => { + cleanup(); + }); + + // When a checkpoint is restored, requests are removed from the model. + // Cancel the background notification and dispose the terminal so that + // background processes don't outlive the rolled-back session state. + const modelChangeListener = sessionRef.object.onDidChange(e => { + if (e.kind === 'removeRequest') { + this._logService.debug(`RunInTerminalTool: Request removed from session, cleaning up background terminal ${termId}`); + RunInTerminalTool._activeExecutions.get(termId)?.dispose(); + RunInTerminalTool._activeExecutions.delete(termId); + cleanup(); + terminalInstance.dispose(); + } + }); + + const cleanup = () => { + listener.dispose(); + disposedListener.dispose(); + modelChangeListener.dispose(); + bgCts?.dispose(); + outputMonitor?.dispose(); + sessionRef.dispose(); + }; + + this._register(listener); + this._register(disposedListener); + this._register(modelChangeListener); + } // #endregion } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 7949d61554896..ea341825e4e25 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -28,6 +28,7 @@ export const enum TerminalChatAgentToolsSettingId { PreventShellHistory = 'chat.tools.terminal.preventShellHistory', EnforceTimeoutFromModel = 'chat.tools.terminal.enforceTimeoutFromModel', DetachBackgroundProcesses = 'chat.tools.terminal.detachBackgroundProcesses', + BackgroundNotifications = 'chat.tools.terminal.backgroundNotifications', IdlePollInterval = 'chat.tools.terminal.idlePollInterval', TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux', @@ -654,6 +655,13 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary; getOS(): Promise; checkForSandboxingPrereqs(forceRefresh?: boolean): Promise; - wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult; + wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult; getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; @@ -200,7 +200,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return this._os; } - public wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult { + public wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult { if (!this._sandboxConfigPath || !this._tempDir) { throw new Error('Sandbox config path or temp dir not initialized'); } @@ -208,7 +208,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const blockedDomainResult = requestUnsandboxedExecution ? { blockedDomains: [], deniedDomains: [] } : this._getBlockedDomains(command); if (!requestUnsandboxedExecution && blockedDomainResult.blockedDomains.length > 0) { return { - command: this._wrapUnsandboxedCommand(command), + command: this._wrapUnsandboxedCommand(command, shell), isSandboxWrapped: false, blockedDomains: blockedDomainResult.blockedDomains, deniedDomains: blockedDomainResult.deniedDomains, @@ -219,7 +219,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // If requestUnsandboxedExecution is true, need to ensure env variables set during sandbox still apply. if (requestUnsandboxedExecution) { return { - command: this._wrapUnsandboxedCommand(command), + command: this._wrapUnsandboxedCommand(command, shell), isSandboxWrapped: false, }; } @@ -453,8 +453,14 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return `'${value.replace(/'/g, `'\\''`)}'`; } - private _wrapUnsandboxedCommand(command: string): string { - return this._tempDir?.path ? `(TMPDIR="${this._tempDir.path}"; export TMPDIR; ${command})` : command; + private _wrapUnsandboxedCommand(command: string, shell?: string): string { + if (!this._tempDir?.path) { + return command; + } + if (!shell) { + return `(TMPDIR="${this._tempDir.path}"; export TMPDIR; ${command})`; + } + return `env TMPDIR="${this._tempDir.path}" ${this._quoteShellArgument(shell)} -c ${this._quoteShellArgument(command)}`; } private _getBlockedDomains(command: string): { blockedDomains: string[]; deniedDomains: string[] } { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 33c026438adee..2f0c346f9c73a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -322,26 +322,40 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test', true).command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test)`); + strictEqual(sandboxService.wrapCommand('echo test', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test'`); }); test('should preserve TMPDIR for piped unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test | cat', true).command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test | cat)`); + strictEqual(sandboxService.wrapCommand('echo test | cat', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test | cat'`); + }); + + test('should preserve trailing backslashes for unsandboxed commands', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + strictEqual(sandboxService.wrapCommand('echo test \\', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test \\'`); + }); + + test('should use fish-compatible wrapping for unsandboxed commands', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + strictEqual(sandboxService.wrapCommand('echo test', true, 'fish').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'fish' -c 'echo test'`); }); test('should switch to unsandboxed execution when a domain is not allowlisted', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com'); + const wrapResult = sandboxService.wrapCommand('curl https://example.com', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Blocked domains should prevent sandbox wrapping'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains should require unsandbox confirmation'); deepStrictEqual(wrapResult.blockedDomains, ['example.com']); - strictEqual(wrapResult.command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; curl https://example.com)`); + strictEqual(wrapResult.command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'curl https://example.com'`); }); test('should allow exact allowlisted domains', async () => { @@ -523,12 +537,12 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = 'echo $HOME $(curl eth0.me) `id`'; - const wrapResult = sandboxService.wrapCommand(command); + const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Commands with blocked domains inside substitutions should not stay sandboxed'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains inside substitutions should require confirmation'); deepStrictEqual(wrapResult.blockedDomains, ['eth0.me']); - strictEqual(wrapResult.command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; ${command})`); + strictEqual(wrapResult.command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo $HOME $(curl eth0.me) \`id\`'`); }); test('should escape single-quote breakout payloads in wrapped command argument', async () => { diff --git a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts index 37fac8335cb35..a4cb032efa757 100644 --- a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts @@ -25,7 +25,7 @@ import { EditorOption } from '../../../../editor/common/config/editorOptions.js' import { Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; -import { IModelDecorationOptions, InjectedTextCursorStops, InjectedTextOptions, ITextModel } from '../../../../editor/common/model.js'; +import { IModelDecorationOptions, InjectedTextCursorStops, InjectedTextOptions, ITextModel, MinimapPosition } from '../../../../editor/common/model.js'; import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -39,6 +39,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { ILogService } from '../../../../platform/log/common/log.js'; import { bindContextKey, observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IQuickInputButton, IQuickInputService, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js'; +import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; import { ActiveEditorContext } from '../../../common/contextkeys.js'; import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js'; import { getTestingConfiguration, TestingConfigKeys } from '../common/configuration.js'; @@ -52,6 +53,7 @@ import { TestingContextKeys } from '../common/testingContextKeys.js'; import * as coverUtils from './codeCoverageDisplayUtils.js'; import { testingCoverageMissingBranch, testingCoverageReport, testingFilterIcon, testingRerunIcon } from './icons.js'; import { ManagedTestCoverageBars } from './testCoverageBars.js'; +import { testingCoveredMinimapBackground, testingUncoveredMinimapBackground } from './theme.js'; const CLASS_HIT = 'coverage-deco-hit'; const CLASS_MISS = 'coverage-deco-miss'; @@ -130,10 +132,11 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri reader => this.hasInlineCoverageDetails.read(reader), )); + const minimapEnabled = observableConfigValue(TestingConfigKeys.CoverageMinimapEnabled, true, configurationService); this._register(autorun(reader => { const c = fileCoverage.read(reader); if (c) { - this.apply(editor.getModel()!, c.file, c.testId, coverage.showInline.read(reader)); + this.apply(editor.getModel()!, c.file, c.testId, coverage.showInline.read(reader), minimapEnabled.read(reader)); } else { this.clear(); } @@ -329,7 +332,7 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri return false; } - private async apply(model: ITextModel, coverage: FileCoverage, testId: TestId | undefined, showInlineByDefault: boolean) { + private async apply(model: ITextModel, coverage: FileCoverage, testId: TestId | undefined, showInlineByDefault: boolean, showMinimap: boolean) { const details = this.details = await this.loadDetails(coverage, testId, model); if (!details) { this.hasInlineCoverageDetails.set(false, undefined); @@ -353,6 +356,10 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri showIfCollapsed: showMissIndicator, // only avoid collapsing if we want to show the miss indicator description: 'coverage-gutter', lineNumberClassName: `coverage-deco-gutter ${cls}`, + minimap: showMinimap ? { + color: themeColorFromId(hits ? testingCoveredMinimapBackground : testingUncoveredMinimapBackground), + position: MinimapPosition.Gutter, + } : undefined, }; const applyHoverOptions = (target: IModelDecorationOptions) => { @@ -383,6 +390,10 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri showIfCollapsed: false, description: 'coverage-inline', lineNumberClassName: `coverage-deco-gutter ${cls}`, + minimap: showMinimap ? { + color: themeColorFromId(detail.count ? testingCoveredMinimapBackground : testingUncoveredMinimapBackground), + position: MinimapPosition.Gutter, + } : undefined, }; const applyHoverOptions = (target: IModelDecorationOptions) => { diff --git a/src/vs/workbench/contrib/testing/browser/theme.ts b/src/vs/workbench/contrib/testing/browser/theme.ts index d2caa0673bc8b..6765208b02c38 100644 --- a/src/vs/workbench/contrib/testing/browser/theme.ts +++ b/src/vs/workbench/contrib/testing/browser/theme.ts @@ -105,6 +105,20 @@ export const testingUncoveredGutterBackground = registerColor('testing.uncovered hcLight: chartsRed }, localize('testing.uncoveredGutterBackground', 'Gutter color of regions where code not covered.')); +export const testingCoveredMinimapBackground = registerColor('testing.coveredMinimapBackground', { + dark: transparent(diffInserted, 0.6), + light: transparent(diffInserted, 0.6), + hcDark: chartsGreen, + hcLight: chartsGreen +}, localize('testing.coveredMinimapBackground', 'Minimap color of regions where code was covered.')); + +export const testingUncoveredMinimapBackground = registerColor('testing.uncoveredMinimapBackground', { + dark: transparent(diffRemoved, 1.5), + light: transparent(diffRemoved, 1.5), + hcDark: chartsRed, + hcLight: chartsRed +}, localize('testing.uncoveredMinimapBackground', 'Minimap color of regions where code was not covered.')); + export const testingCoverCountBadgeBackground = registerColor('testing.coverCountBadgeBackground', badgeBackground, localize('testing.coverCountBadgeBackground', 'Background for the badge indicating execution count')); export const testingCoverCountBadgeForeground = registerColor('testing.coverCountBadgeForeground', badgeForeground, localize('testing.coverCountBadgeForeground', 'Foreground for the badge indicating execution count')); diff --git a/src/vs/workbench/contrib/testing/common/configuration.ts b/src/vs/workbench/contrib/testing/common/configuration.ts index 5843924ee7c0a..9c8c1f96b5764 100644 --- a/src/vs/workbench/contrib/testing/common/configuration.ts +++ b/src/vs/workbench/contrib/testing/common/configuration.ts @@ -25,6 +25,7 @@ export const enum TestingConfigKeys { ShowCoverageInExplorer = 'testing.showCoverageInExplorer', CoverageBarThresholds = 'testing.coverageBarThresholds', CoverageToolbarEnabled = 'testing.coverageToolbarEnabled', + CoverageMinimapEnabled = 'testing.coverageMinimapEnabled', ResultsViewLayout = 'testing.resultsView.layout', } @@ -197,6 +198,11 @@ export const testingConfiguration: IConfigurationNode = { type: 'boolean', default: false, // todo@connor4312: disabled by default until UI sync }, + [TestingConfigKeys.CoverageMinimapEnabled]: { + description: localize('testing.coverageMinimapEnabled', 'Controls whether coverage indicators are shown in the minimap.'), + type: 'boolean', + default: true, + }, [TestingConfigKeys.ResultsViewLayout]: { description: localize('testing.resultsView.layout', 'Controls the layout of the Test Results view.'), enum: [ @@ -246,6 +252,7 @@ export interface ITestingConfiguration { [TestingConfigKeys.ShowCoverageInExplorer]: boolean; [TestingConfigKeys.CoverageBarThresholds]: ITestingCoverageBarThresholds; [TestingConfigKeys.CoverageToolbarEnabled]: boolean; + [TestingConfigKeys.CoverageMinimapEnabled]: boolean; [TestingConfigKeys.ResultsViewLayout]: TestingResultsViewLayout; } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index af705e9627607..5e354883f7785 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -127,6 +127,14 @@ declare module 'vscode' { * Whether any hooks are enabled for this request. */ readonly hasHooksEnabled: boolean; + + /** + * When true, this request was initiated by the system (e.g. a terminal + * command completion notification) rather than by the user typing a + * message. Extensions can use this to render the prompt differently + * and skip billing. + */ + readonly isSystemInitiated?: boolean; } export enum ChatRequestEditedFileEventKind { diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index b55537066e144..33027458d2c7c 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -25,6 +25,8 @@ declare module 'vscode' { static readonly Prompt: ChatSessionCustomizationType; /** Hook customization (event-driven automation). */ static readonly Hook: ChatSessionCustomizationType; + /** Plugin customization (agent runtime plugins). */ + static readonly Plugins: ChatSessionCustomizationType; /** * The string identifier for this customization type. @@ -56,11 +58,11 @@ declare module 'vscode' { readonly iconId?: string; /** - * Customization types that this provider does **not** support. - * The corresponding sections will be hidden in the management UI - * when this provider is active. + * Customization types that this provider supports. + * Only the corresponding sections will be shown in the management UI + * when this provider is active. When omitted, all sections are shown. */ - readonly unsupportedTypes?: readonly ChatSessionCustomizationType[]; + readonly supportedTypes?: readonly ChatSessionCustomizationType[]; } /**